From 0e78694c727c524d56be1c4f73f4bc2e33a95b91 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:07:39 -0700 Subject: [PATCH] =?UTF-8?q?simplify(compat):=20hermes=5Fcli.main=20?= =?UTF-8?q?=E2=80=94=20drop=20198=20re-exports/aliases=20(155=20eager=20+?= =?UTF-8?q?=2045=20lazy=20PEP=20562=20+=20=5Fwarn=5Fstale=5Fdashboard=5Fpr?= =?UTF-8?q?ocesses=20alias=20+=20=5Ftime/=5Fself/=5FLAZY=5F*=20machinery),?= =?UTF-8?q?=20repoint=2017=20source=20callers=20+=2091=20test=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent/plugin_llm.py | 2 +- hermes_cli/_install_repair.py | 2 +- hermes_cli/_scan_venv_blockers.py | 2 +- hermes_cli/dashboard_procs.py | 35 +- hermes_cli/main.py | 335 ++++-------------- hermes_cli/main_agent_cmds.py | 7 +- hermes_cli/main_dashboard.py | 19 +- hermes_cli/main_desktop.py | 35 +- hermes_cli/main_install_repair.py | 33 +- hermes_cli/main_provider_setup.py | 6 +- hermes_cli/main_tui_launch.py | 12 +- hermes_cli/main_web_build.py | 11 +- hermes_cli/model_setup_flows.py | 12 +- hermes_cli/model_setup_flows_common.py | 4 +- hermes_cli/model_setup_flows_custom.py | 4 +- hermes_cli/profile_cmd.py | 5 +- hermes_cli/setup_quick.py | 2 +- hermes_cli/subcommands/sessions.py | 2 +- tests/agent/test_plugin_llm_task_routing.py | 2 +- tests/cli/test_cli_insights_command.py | 2 +- tests/cli/test_cli_provider_resolution.py | 10 +- tests/cli/test_update_command.py | 3 +- tests/gateway/test_update_streaming.py | 6 +- .../test_anthropic_model_flow_stale_oauth.py | 4 +- tests/hermes_cli/test_anthropic_oauth_flow.py | 4 +- tests/hermes_cli/test_api_key_providers.py | 6 +- tests/hermes_cli/test_auth_qwen_provider.py | 2 +- ..._authenticated_providers_exhausted_pool.py | 3 +- tests/hermes_cli/test_aux_config.py | 9 +- tests/hermes_cli/test_aux_picker_inventory.py | 6 +- tests/hermes_cli/test_backup.py | 4 +- tests/hermes_cli/test_bytecode_sweep.py | 5 +- .../test_checkout_mutation_guards.py | 6 +- tests/hermes_cli/test_clear_stale_base_url.py | 4 +- tests/hermes_cli/test_cmd_update.py | 51 +-- tests/hermes_cli/test_cmd_update_apt.py | 3 +- tests/hermes_cli/test_cmd_update_docker.py | 3 +- tests/hermes_cli/test_codex_models.py | 2 +- .../test_custom_provider_model_switch.py | 20 +- .../test_dashboard_lifecycle_flags.py | 14 +- .../test_dashboard_unified_launch.py | 6 +- .../hermes_cli/test_desktop_exe_integrity.py | 29 +- tests/hermes_cli/test_ensure_acp_launcher.py | 2 +- .../test_gemini_free_tier_setup_block.py | 6 +- tests/hermes_cli/test_gmi_provider.py | 2 +- tests/hermes_cli/test_gui_command.py | 256 ++++++------- tests/hermes_cli/test_lazy_command_exports.py | 65 ++-- .../test_lazy_refresh_venv_repair.py | 16 +- tests/hermes_cli/test_linux_sandbox_fixup.py | 56 +-- .../test_model_flow_pooled_credentials.py | 2 +- .../test_model_provider_persistence.py | 6 +- .../test_model_switch_custom_providers.py | 2 +- tests/hermes_cli/test_models.py | 6 +- .../test_models_dev_preferred_merge.py | 2 +- tests/hermes_cli/test_pin_kanban_board_env.py | 5 +- .../hermes_cli/test_plugin_auxiliary_tasks.py | 4 +- tests/hermes_cli/test_process_identity.py | 1 + .../test_profile_export_default_path.py | 5 +- .../test_profile_install_env_encoding.py | 2 +- tests/hermes_cli/test_prompt_api_key.py | 5 +- .../hermes_cli/test_psutil_android_extract.py | 3 +- .../test_quarantine_noop_restore.py | 19 +- .../test_quarantine_orphan_rescue.py | 13 +- .../hermes_cli/test_reasoning_effort_menu.py | 2 +- .../hermes_cli/test_run_with_idle_timeout.py | 2 +- tests/hermes_cli/test_session_browse.py | 4 +- .../test_sessions_size_delta_label.py | 2 +- .../test_shim_fail_closed_windows_live.py | 7 +- tests/hermes_cli/test_slack_cli.py | 1 + .../test_ssh_session_token_parser.py | 3 +- tests/hermes_cli/test_stale_pid_guard.py | 12 +- tests/hermes_cli/test_state_db_guard.py | 4 +- .../test_terminal_menu_fallbacks.py | 2 +- tests/hermes_cli/test_tui_bundled.py | 2 +- tests/hermes_cli/test_tui_heap_sizing.py | 11 +- tests/hermes_cli/test_tui_npm_install.py | 91 ++--- tests/hermes_cli/test_tui_resume_flow.py | 11 +- tests/hermes_cli/test_update_autostash.py | 15 +- .../test_update_concurrent_quarantine.py | 29 +- .../test_update_fleet_check_fail_closed.py | 2 +- .../test_update_fleet_probe_resume_token.py | 2 +- .../test_update_fleet_restart_timeout.py | 6 +- .../test_update_gateway_launcher_refresh.py | 3 +- .../test_update_gateway_restart_aborted.py | 6 +- .../test_update_handoff_backend_reap.py | 1 + .../test_update_hangup_protection.py | 10 +- tests/hermes_cli/test_update_import_guard.py | 24 +- .../test_update_interrupted_recovery.py | 8 +- .../test_update_orphan_backend_reap.py | 3 +- .../test_update_post_pull_syntax_guard.py | 9 +- tests/hermes_cli/test_update_self_lock.py | 2 +- .../test_update_serve_generation_recovery.py | 23 +- .../test_update_shim_fail_closed.py | 40 +-- .../hermes_cli/test_update_shim_self_lock.py | 39 +- .../hermes_cli/test_update_stale_dashboard.py | 119 +++---- .../test_update_stale_virtualenv.py | 18 +- tests/hermes_cli/test_update_venv_health.py | 3 +- .../test_update_zip_atomic_replace.py | 2 +- .../test_update_zip_fallback_guards.py | 2 +- .../test_update_zip_symlink_reject.py | 5 +- tests/hermes_cli/test_update_zip_two_phase.py | 3 +- .../hermes_cli/test_verify_console_scripts.py | 13 +- .../test_verify_core_dependencies.py | 22 +- tests/hermes_cli/test_web_ui_build.py | 54 ++- .../test_whatsapp_setup_ordering.py | 4 +- .../test_session_lifecycle_status.py | 2 +- tests/honcho_plugin/test_query_rewrite.py | 2 +- tests/test_lazy_secrets_dispatch.py | 5 +- 108 files changed, 809 insertions(+), 1031 deletions(-) diff --git a/agent/plugin_llm.py b/agent/plugin_llm.py index 0fb46fd42a..1da3ef7464 100644 --- a/agent/plugin_llm.py +++ b/agent/plugin_llm.py @@ -197,7 +197,7 @@ def _resolve_task_ownership(plugin_id: str) -> tuple[frozenset, frozenset]: except Exception: # pragma: no cover — registry unavailable pass try: - from hermes_cli.main import _AUX_TASKS + from hermes_cli.main_provider_setup import _AUX_TASKS builtin = {k for k, _name, _desc in _AUX_TASKS} except Exception: # pragma: no cover — main import failure pass diff --git a/hermes_cli/_install_repair.py b/hermes_cli/_install_repair.py index 743407e691..f3518280c8 100644 --- a/hermes_cli/_install_repair.py +++ b/hermes_cli/_install_repair.py @@ -2,7 +2,7 @@ Callers: ``_early_recovery.recover_if_needed`` (stdlib-only, runs BEFORE ``hermes_cli.main``'s third-party imports so a pending update completes while no native extension is mapped yet) and -``hermes_cli.main._recover_core_update_marker_locked`` (the post-import recovery path). +``hermes_cli.main_install_repair._recover_core_update_marker_locked`` (the post-import recovery path). Deliberately **stdlib-only** so importing it can never fail in the corrupted-venv state it exists to repair; ``managed_uv`` and friends belong to the late path only. """ diff --git a/hermes_cli/_scan_venv_blockers.py b/hermes_cli/_scan_venv_blockers.py index c628547dfd..eb60c79222 100644 --- a/hermes_cli/_scan_venv_blockers.py +++ b/hermes_cli/_scan_venv_blockers.py @@ -255,7 +255,7 @@ def main() -> None: except Exception as exc: _emit_probe_fail(f"psutil is not available: {exc}") try: - from hermes_cli.main import _detect_venv_python_processes # noqa: PLC0415 + from hermes_cli.update_cmd import _detect_venv_python_processes matches = _detect_venv_python_processes() except Exception as exc: diff --git a/hermes_cli/dashboard_procs.py b/hermes_cli/dashboard_procs.py index 79424c1bd5..38a5a99291 100644 --- a/hermes_cli/dashboard_procs.py +++ b/hermes_cli/dashboard_procs.py @@ -1,7 +1,7 @@ """Dashboard process-hygiene helpers — extracted from ``hermes_cli/main.py``. -Helpers that STAY in ``hermes_cli.main`` are reached through the lazy ``_m()`` reference so -monkeypatches on ``hermes_cli.main.`` keep working and imports stay one-way. +Helpers defined in ``hermes_cli.main_dashboard`` / ``hermes_cli.main_install_repair`` are imported at +call time so imports stay one-way (both of those modules import this one lazily). """ import contextlib @@ -19,12 +19,6 @@ _DASHBOARD_PATTERNS = tuple( _PS_RUN_KWARGS = dict(capture_output=True, text=True, encoding="utf-8", errors="replace") -def _m(): - """Lazy ``hermes_cli.main`` reference (call-time; keeps patches working).""" - from hermes_cli import main - return main - - def _empty_result() -> dict[str, list]: return {"matched": [], "killed": [], "failed": []} @@ -332,10 +326,12 @@ def _kill_stale_dashboard_processes( function runs. Without excluding them, a Serve-only install's freshly restarted process is found again here and restarted a second time for no benefit (review on #83595). """ - if restart_managed and _m()._restart_managed_dashboard_service(reason): + from hermes_cli import main_dashboard as _dash + + if restart_managed and _dash._restart_managed_dashboard_service(reason): # The dashboard unit is handled but other backends (e.g. hermes-serve.service) are not: # mark the unit handled so the filter below drops its PIDs, and keep going. - _dash_unit = getattr(_m(), "_DASHBOARD_SYSTEMD_UNIT", "hermes-dashboard.service") + _dash_unit = getattr(_dash, "_DASHBOARD_SYSTEMD_UNIT", "hermes-dashboard.service") already_restarted_units = set(already_restarted_units or ()) | { str(_dash_unit).removesuffix(".service")} exclude = _exclude_pids_from_env() @@ -343,7 +339,7 @@ def _kill_stale_dashboard_processes( # An SSH-owned backend belongs to an attached Desktop client; killing it strands that # client's fixed SSH port-forward. Same ownership records as the reaper. exclude |= _lock_owned_serve_pids() - pids = _m()._find_stale_dashboard_pids(exclude_pids=exclude or None) + pids = _dash._find_stale_dashboard_pids(exclude_pids=exclude or None) if not pids: return _empty_result() # Snapshot systemd unit/cgroup and argv BEFORE killing (the cgroup dies with the process). @@ -353,9 +349,9 @@ def _kill_stale_dashboard_processes( pid_home: dict[int, str | None] = {} if restart_managed and sys.platform != "win32": for pid in pids: - pid_cgroup[pid] = _m()._get_pid_cgroup_path(pid) - pid_service[pid] = _m()._get_systemd_service_for_pid(pid) - if not pid_service[pid] and (cmdline := _m()._dashboard_cmdline_for_pid(pid)): + pid_cgroup[pid] = _dash._get_pid_cgroup_path(pid) + pid_service[pid] = _dash._get_systemd_service_for_pid(pid) + if not pid_service[pid] and (cmdline := _dash._dashboard_cmdline_for_pid(pid)): # Manual process: exact argv + HERMES_HOME for the respawn and its profile cap. # Manually-started process: preserve its exact argv so we can respawn it after the update # (#40449, #68934). Snapshot HERMES_HOME before the kill so per-profile caps still work @@ -394,6 +390,7 @@ def _restart_killed_backends( # back after our clean SIGTERM, and the Desktop can't reconnect (#68934). Filtered so Desktop # ``serve|dashboard --port 0`` backends are not resurrected and duplicates collapse to one per profile # (#78821). + from hermes_cli import main_dashboard as _dash unrecovered: list[int] = [] failed_restarts: list[tuple[str, str]] = [] seen_services: set[str] = set() @@ -404,7 +401,7 @@ def _restart_killed_backends( if svc_name in seen_services: continue seen_services.add(svc_name) - if _m()._try_restart_systemd_service(svc_name, pid_cgroup.get(pid)): + if _dash._try_restart_systemd_service(svc_name, pid_cgroup.get(pid)): print(f" ✓ restarted systemd service {svc_name}") else: failed_restarts.append((svc_name, "systemctl restart returned non-zero")) @@ -416,7 +413,7 @@ def _restart_killed_backends( for svc, err in failed_restarts: print(f" ⚠ {svc}: {err}") respawn_cmds = _filter_dashboard_respawn_candidates(respawn_candidates) - failed_cmds = _m()._respawn_dashboard_processes(respawn_cmds) if respawn_cmds else None + failed_cmds = _dash._respawn_dashboard_processes(respawn_cmds) if respawn_cmds else None if failed_cmds: unrecovered.extend(p for p in killed if pid_cmdline.get(p) in failed_cmds) if failed_restarts or unrecovered: @@ -442,13 +439,15 @@ def _detect_concurrent_hermes_instances( ``python.exe``); ``proc.parents()`` at once because a per-hop loop bailed on the first AccessDenied. Empty off-Windows / without psutil. Never raises. """ - if not _m()._is_windows(): + from hermes_cli.main_install_repair import _hermes_exe_shims, _is_windows + + if not _is_windows(): return [] try: import psutil except Exception: return [] - shim_paths = {_norm_exe(shim) for shim in _m()._hermes_exe_shims(scripts_dir)} + shim_paths = {_norm_exe(shim) for shim in _hermes_exe_shims(scripts_dir)} if not shim_paths: return [] seed = int(exclude_pid) if exclude_pid is not None else os.getpid() diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 99171b98e8..f624da99bf 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -646,13 +646,10 @@ if _FORCE_IPV4_EARLY: import logging import threading -import time as _time # noqa: F401 (tests patch hermes_cli.main._time.sleep) from datetime import datetime from hermes_cli import __version__, __release_date__ -# Re-imported so select_provider_and_model and test monkeypatches on -# hermes_cli.main._model_flow_* keep resolving unchanged. from hermes_cli.model_setup_flows import ( _model_flow_openrouter, _model_flow_nous, @@ -675,8 +672,7 @@ from hermes_cli.model_setup_flows import ( _model_flow_ai_gateway, ) logger = logging.getLogger(__name__) -from hermes_cli.main_agent_cmds import ( # noqa: E402,F401 (re-exported; tests patch hermes_cli.main.) - _cmd_skills_trust, +from hermes_cli.main_agent_cmds import ( cmd_acp, cmd_insights, cmd_memory, @@ -684,230 +680,87 @@ from hermes_cli.main_agent_cmds import ( # noqa: E402,F401 (re-exported; tests cmd_skills, cmd_tools, ) -from hermes_cli.main_platform_setup import ( # noqa: E402,F401 (re-exported; tests patch hermes_cli.main.) +from hermes_cli.main_platform_setup import ( cmd_slack, cmd_sync, cmd_whatsapp, cmd_whatsapp_cloud, ) -from hermes_cli.main_dashboard import ( # noqa: E402,F401 (re-exported; tests patch hermes_cli.main.) - _DASHBOARD_SYSTEMD_UNIT, - _UpdateOutputStream, - _dashboard_cmdline_for_pid, - _dashboard_listening, - _dashboard_probe_host, - _extract_scope_from_cgroup, +from hermes_cli.main_dashboard import ( _finalize_update_output, _find_stale_dashboard_pids, - _get_pid_cgroup_path, - _get_systemd_service_for_pid, _install_hangup_protection, _is_electron_packaged_web_dist, _maybe_setup_dashboard_auth_interactively, - _parse_dashboard_runtime, _read_ssh_session_token_file, _report_dashboard_status, _resolve_dashboard_web_dist, - _respawn_dashboard_processes, - _restart_managed_dashboard_service, _route_named_profile_dashboard, - _try_restart_systemd_service, ) -from hermes_cli.main_provider_setup import ( # noqa: E402,F401 (re-exported; tests patch hermes_cli.main.) - _AUX_TASKS, - _build_provider_picker_rows, - _named_custom_provider_map, - _DEFAULT_QWEN_PORTAL_MODELS, - _DELEGATION_TASK_DESC, - _DELEGATION_TASK_KEY, - _DELEGATION_TASK_NAME, +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, - _all_aux_tasks, - _auto_provider_name, _aux_config_menu, - _aux_flow_custom_endpoint, - _aux_flow_provider_model, - _aux_select_for_task, - _aux_task_display_name, + _build_provider_picker_rows, _clear_stale_openai_base_url, - _custom_provider_api_key_config_value, - _custom_provider_base_url_config_value, - _delegation_cfg_as_task, - _format_aux_current, - _infer_stepfun_region, _is_profile_api_key_provider, - _prompt_api_key, - _prompt_custom_api_mode_selection, + _named_custom_provider_map, _prompt_provider_choice, - _prompt_reasoning_effort_selection, _remove_custom_provider, - _reset_aux_to_auto, - _run_anthropic_oauth_flow, - _save_aux_choice, - _save_custom_provider, - _stepfun_base_url_for_region, ) -from hermes_cli.main_install_repair import ( # noqa: E402,F401 (re-exported; tests patch hermes_cli.main.) - ShimQuarantineError, - _LAZY_REFRESH_IMPORT_PROBES, - _LAZY_REFRESH_REPAIR_PACKAGES, - _PENDING_RENAME_KEY, - _PENDING_RENAME_VALUE, - _QUARANTINE_GRACE_SECONDS, - _UPDATE_REEXEC_ENV, - _cleanup_pending_shim_renames, +from hermes_cli.main_install_repair import ( _cleanup_quarantined_exes, + _recover_from_interrupted_install, +) +from hermes_cli.main_install_repair import ( # frozen updater surface: update_cmd*.py resolve these via _m() + ShimQuarantineError, + _UPDATE_REEXEC_ENV, _clear_lazy_refresh_incomplete_marker, _clear_marker_file, _clear_update_incomplete_marker, - _default_venv_install_target, - _detect_broken_lazy_refresh_imports, - _filter_pending_shim_renames, - _hermes_exe_shims, - _insert_python_pin, _install_python_dependencies_with_optional_fallback, - _interpreter_scripts_dir, _is_termux_env, - _is_uv_command, _is_windows, _is_windows_npm_path, _lazy_refresh_marker_path, - _lazy_refresh_repair_specs, - _load_console_script_names, - _load_installable_optional_extras, - _norm_exe_path, _pytest_owns_live_checkout, - _quarantine_running_hermes_exe, - _quarantine_stamp_ms, - _recover_core_update_marker_locked, - _recover_from_interrupted_install, - _recover_lazy_refresh_marker_locked, _reexec_dependency_sync_off_windows_shim, - _repair_broken_lazy_refresh_imports, _repair_venv_via_import_probes, _resolve_install_target_python, _resolve_node_runtime_npm, _resolve_update_branch, - _restore_quarantined_exes, _run_install_with_heartbeat, _run_package_only_install, - _run_quarantined_install, _update_marker_path, _venv_scripts_dir, _verify_console_scripts_installed, _verify_core_dependencies_installed, - _windows_running_hermes_launcher_locked, - _windows_shim_in_process_chain, ) -from hermes_cli.main_desktop import ( # noqa: E402,F401 (re-exported; tests patch hermes_cli.main.) - _DESKTOP_PREVIOUS_SUFFIX, - _DESKTOP_STAGING_PREFIX, - _ELECTRON_FALLBACK_MIRROR, - _HTML_TAG_WITH_URL, - _LINUX_PASSWORD_STORES, - _MACHINE_ATTRIBUTE_USER_ENABLED, - _MODULE_TAG, - _PE_MACHINE_AMD64, - _PE_MACHINE_ARM64, - _PE_MACHINE_I386, - _PE_MACHINE_NAMES, - _PE_MACHINE_TO_NAME, - _build_desktop_app, - _compute_desktop_content_hash, - _desktop_backup_unpacked_dir, - _desktop_build_needed, - _desktop_dist_exists, - _desktop_exe_integrity_error, - _desktop_launch_options, - _desktop_linux_needs_disable_setuid_sandbox, - _desktop_linux_needs_no_sandbox, - _desktop_linux_sandbox_fixup, - _desktop_linux_sandbox_helper_is_regular_file, - _desktop_linux_userns_sandbox_available, - _desktop_macos_bundle_id, - _desktop_macos_has_valid_real_signature, - _desktop_macos_local_codesign, - _desktop_macos_local_signing_identity, - _desktop_macos_relaunchable_fixup, - _desktop_macos_setup_tcc_identity, - _desktop_packaged_executable, - _desktop_packaged_executable_in, - _desktop_staging_dir, - _desktop_stamp_path, - _desktop_unpacked_root, - _detect_linux_password_store, - _discard_desktop_staging, - _electron_dir, - _electron_dist_binary, - _electron_dist_ok, - _electron_download_cache_dirs, - _electron_pkg_staged_missing_dist, - _ensure_desktop_exe_launchable, - _expected_windows_pe_machines, - _force_adhoc_macos_signing, - _macos_codesigning_identity_valid, - _parse_pe_machine, - _pe_machine_or_none, - _purge_electron_build_cache, - _redownload_electron_dist, - _register_linux_desktop_entry, - _renderer_bundle_dir, - _renderer_bundle_torn, - _rollback_desktop_from_backup, - _stop_desktop_processes_locking_build, - _swap_staged_desktop_app, - _try_redownload_electron_dist, - _windows_native_machine, - _windows_native_machine_from_iswow64, - _windows_user_runnable_pe_machines, - _write_desktop_build_stamp, +from hermes_cli.main_desktop import ( cmd_gui, ) -from hermes_cli.main_web_build import ( # noqa: E402,F401 (re-exported; tests patch hermes_cli.main.) - _BYTECODE_FINGERPRINT_FILE, +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, +) +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, - _compute_web_ui_content_hash, - _do_build_web_ui, - _missing_web_build_tool, _nixos_build_env, _record_bytecode_fingerprint, _run_npm_install_deterministic, - _run_npm_watching_for_engine_failure, - _run_with_idle_timeout, - _sweep_stale_bytecode_if_checkout_changed, - _web_ui_build_needed, - _web_ui_stamp_path, - _write_web_ui_build_stamp, ) -from hermes_cli.main_tui_launch import ( # noqa: E402,F401 (re-exported; tests patch hermes_cli.main.) - _NPM_LOCK_RUNTIME_KEYS, - _TUI_BUILD_INPUT_DIRS, - _TUI_BUILD_INPUT_FILES, - _TUI_BUILD_INPUT_SUFFIXES, - _apply_tui_python_env, - _ensure_tui_node, - _ensure_tui_workspace, - _find_bundled_tui, - _iter_tui_build_inputs, +from hermes_cli.main_tui_launch import ( _launch_tui, - _make_tui_argv, - _normalize_tui_toolsets, - _npm_lifecycle_env, - _npm_lock_workspace_closure, _pin_kanban_board_env, - _print_tui_exit_summary, - _read_cgroup_memory_limit, - _read_tui_active_session_file, - _resolve_tui_heap_mb, _resolve_use_tui, - _restore_tui_workspace, - _safe_tui_cwd, _sync_bundled_skills_quietly, - _termux_workspace_install_context, - _tui_need_npm_install, - _tui_need_rebuild, - _tui_selected_workspace_keys, - _workspace_root, ) @@ -2155,97 +2008,51 @@ def select_provider_and_model(args=None): _clear_stale_openai_base_url() -# Lazy re-exports (PEP 562 ``__getattr__`` below): tests and callers read -# ``hermes_cli.main.`` for the sessions/update/dashboard surfaces split -# out of this file. Importing them eagerly costs ~50-100ms per invocation. -# patch.object sets a real module attribute, which shadows __getattr__. -_LAZY_COMMAND_EXPORTS = { - "hermes_cli.sessions_cmd": ( - "_annotate_session_statuses", - "_relative_time", - "_session_browse_picker", - "_session_status_tag", - "_size_delta_label", - "cmd_sessions", - ), - "hermes_cli.dashboard_procs": ( - "_detect_concurrent_hermes_instances", - "_kill_stale_dashboard_processes", - "_scan_dashboard_processes", - ), +# Frozen updater surface (PEP 562 ``__getattr__`` below): the frozen +# ``hermes_cli/update_cmd*.py`` files resolve these names via ``_m().`` +# 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": ( - "_web_toolchain_roots", "_web_build_toolchain_ready", - "_warn_incomplete_gateway_fleet_restart", "_warn_gateway_restart_phase_aborted", - "_surviving_gateway_pids_after_failed_restart", - "_service_unit_supports_graceful_sigusr1_restart", "_restart_phase_failure_is_incomplete", - "_print_update_completion", "_log_only_write", "_for_each_systemd_gateway_unit", - "_print_curator_recent_run_notice", "_ORPHAN_RESCUE_REFS_TO_KEEP", - "_ORPHAN_RESCUE_REF_MAX_AGE_DAYS", "_UPDATE_CRITICAL_FILES", "_abort_dependency_sync_if_self_locked", "_assess_parked_branch_switch", - "_atomic_replace_dir", "_capture_active_lazy_features", "_capture_active_tool_dependencies", - "_capture_head_sha", "_classify_concurrent_instance", "_cmd_update_check", - "_cmd_update_impl", "_cold_start_windows_gateway_after_update", - "_defer_update_for_self_lock", "_dependency_sync_would_rewrite", - "_detect_self_loaded_native_modules", "_detect_venv_python_processes", - "_discard_lockfile_churn", "_discard_stashed_changes", "_ensure_acp_launcher", - "_ensure_uv_for_termux", "_filter_non_gateway_concurrent_instances", - "_finish_dashboard_update_cleanup", "_fleet_probe_expected_runtimes", "_format_time_ago", - "_gateway_prompt", "_get_origin_url", "_handoff_reapable_backend_pids", - "_install_psutil_android_compat", "_is_fork", "_ledger_manual_serve_holders", + "_capture_active_lazy_features", "_capture_active_tool_dependencies", + "_cold_start_windows_gateway_after_update", "_defer_update_for_self_lock", + "_dependency_sync_would_rewrite", "_detect_self_loaded_native_modules", + "_detect_venv_python_processes", "_discard_stashed_changes", + "_filter_non_gateway_concurrent_instances", "_fleet_probe_expected_runtimes", + "_get_origin_url", "_handoff_reapable_backend_pids", "_ledger_manual_serve_holders", "_ledger_reapable_backend_pids", "_leftover_pausable_gateway_pids", "_npm_lockfile_changed", - "_npm_manifests_digest", "_orphaned_desktop_backend_pids", "_park_stashed_changes", + "_orphaned_desktop_backend_pids", "_park_stashed_changes", "_pause_windows_gateways_for_update", "_print_parked_branch_kept_notice", - "_print_parked_branch_skip_warning", "_prune_orphan_rescue_refs", - "_purge_stale_hermes_modules", "_record_npm_lockfile_hash", "_refresh_active_lazy_features", - "_refresh_active_memory_provider_dependencies", "_refresh_bootstrap_cache_scripts", - "_refresh_windows_gateway_launchers", "_relaunch_stopped_serves", - "_reload_updated_runtime_modules", "_restore_active_tool_dependencies", - "_restore_stashed_changes", "_resume_windows_gateways_after_update", - "_run_logged_subprocess", "_run_pre_update_backup", "_stash_local_changes_if_needed", - "_stop_process_trees", "_sync_with_upstream_if_needed", "_update_node_dependencies", - "_update_via_zip", "_upgrade_pip_before_lazy_refresh", "_validate_critical_files_syntax", - "_validate_critical_modules_import", "_venv_launcher_ancestors", + "_print_parked_branch_skip_warning", "_purge_stale_hermes_modules", + "_refresh_active_lazy_features", "_refresh_active_memory_provider_dependencies", + "_refresh_bootstrap_cache_scripts", "_refresh_windows_gateway_launchers", + "_relaunch_stopped_serves", "_reload_updated_runtime_modules", + "_restore_active_tool_dependencies", "_restore_stashed_changes", + "_resume_windows_gateways_after_update", "_run_logged_subprocess", "_run_pre_update_backup", + "_stash_local_changes_if_needed", "_stop_process_trees", "_sync_with_upstream_if_needed", + "_upgrade_pip_before_lazy_refresh", "_venv_launcher_ancestors", "_wait_for_windows_update_gateway_exit", "_warn_orphaned_update_autostashes", - "_warn_pending_fleet_restart_on_startup", "_write_marker_file", "_write_update_incomplete_marker", ), -} - -# name -> (module, attr), plus the model catalog and one back-compat alias -# (warn-only ``_warn_stale_dashboard_processes`` → the kill helper). -_LAZY_ATTR_SOURCES: dict[str, tuple[str, str]] = { - attr: (module, attr) for module, attrs in _LAZY_COMMAND_EXPORTS.items() for attr in attrs -} -_LAZY_ATTR_SOURCES.update({ - "_PROVIDER_MODELS": ("hermes_cli.models", "_PROVIDER_MODELS"), - "_warn_stale_dashboard_processes": ( - "hermes_cli.dashboard_procs", - "_kill_stale_dashboard_processes", + "hermes_cli.dashboard_procs": ( + "_detect_concurrent_hermes_instances", "_kill_stale_dashboard_processes", ), -}) - - -def _self(): - """This module, for attribute access at call time. - - Bare-name globals don't go through PEP 562 __getattr__, so internal callers - of lazily re-exported names use _self().; monkeypatches on - hermes_cli.main. keep working. ``sys`` is imported locally because - some tests patch this module's ``sys`` attribute. - """ - import sys as _sys - - return _sys.modules[__name__] +} +_FROZEN_ATTR_SOURCES: dict[str, str] = { + attr: module for module, attrs in _FROZEN_UPDATER_SURFACE.items() for attr in attrs +} def __getattr__(name): - """Defer the model-catalog and command-module imports until first read.""" - source = _LAZY_ATTR_SOURCES.get(name) - if source is None: + """Resolve the frozen updater surface on first read (see _FROZEN_UPDATER_SURFACE).""" + 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(source[0]), source[1]) + value = getattr(importlib.import_module(module), name) globals()[name] = value # cache: later accesses skip __getattr__ return value @@ -2419,7 +2226,9 @@ def _update_preflight_handled(args) -> bool: if getattr(args, "check", False): # --check honors --branch so its answer matches what update would pull. branch = _resolve_update_branch(args) - _self()._cmd_update_check( + from hermes_cli.update_cmd import _cmd_update_check + + _cmd_update_check( branch=branch, branch_explicit=bool(getattr(args, "branch", None)), ) @@ -2452,8 +2261,10 @@ def cmd_update(args): # Exit code for the Windows hand-off child's hard exit (see finally); None # = not SystemExit-shaped, so real exceptions keep their traceback. _update_handoff_exit_code: int | None = None + from hermes_cli.update_cmd import _cmd_update_impl + try: - _self()._cmd_update_impl(args, gateway_mode=gateway_mode) + _cmd_update_impl(args, gateway_mode=gateway_mode) 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 @@ -2530,7 +2341,7 @@ def _coalesce_session_name_args(argv: list) -> list: return result -from hermes_cli.profile_cmd import cmd_profile, _render_distribution_plan # noqa: E402,F401 (re-export) +from hermes_cli.profile_cmd import cmd_profile def _dashboard_lifecycle_flags(args, token_file) -> None: @@ -2546,7 +2357,9 @@ def _dashboard_lifecycle_flags(args, token_file) -> None: sys.exit(0) # Reuse the same SIGTERM-grace-SIGKILL path used after `hermes update`; # it prints outcomes itself. Exit 1 only if every pid was unkillable. - _self()._kill_stale_dashboard_processes(reason="requested via --stop") + from hermes_cli.dashboard_procs import _kill_stale_dashboard_processes + + _kill_stale_dashboard_processes(reason="requested via --stop") sys.exit(1 if _find_stale_dashboard_pids() else 0) @@ -3328,6 +3141,13 @@ def _register_plugin_cli_commands(subparsers) -> None: 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)``. @@ -3417,8 +3237,7 @@ def _build_cli_parser(): build_tools_parser(subparsers, cmd_tools=cmd_tools) build_computer_use_parser(subparsers) build_mcp_parser(subparsers, cmd_mcp=cmd_mcp) - # Lazy: sessions_cmd imports only when run; main.cmd_sessions patches keep working. - build_sessions_parser(subparsers, cmd_sessions=lambda a, **kw: _self().cmd_sessions(a, **kw)) + build_sessions_parser(subparsers, cmd_sessions=_cmd_sessions_lazy) build_insights_parser(subparsers, cmd_insights=cmd_insights) build_monitoring_parser(subparsers, cmd_monitoring=cmd_monitoring) build_claw_parser(subparsers, cmd_claw=cmd_claw) @@ -3522,6 +3341,8 @@ def main(): except Exception: pass try: + from hermes_cli.update_cmd_fleet import _warn_pending_fleet_restart_on_startup + _warn_pending_fleet_restart_on_startup() except Exception: pass diff --git a/hermes_cli/main_agent_cmds.py b/hermes_cli/main_agent_cmds.py index 2e51c7f8c6..1b5264703b 100644 --- a/hermes_cli/main_agent_cmds.py +++ b/hermes_cli/main_agent_cmds.py @@ -1,10 +1,7 @@ """Agent-facing subcommand handlers: memory, acp, tools, insights, monitoring, skills (+trust). -Split out of ``hermes_cli/main.py``; every moved name is re-imported there, so -``hermes_cli.main.`` keeps resolving (and monkeypatching) as before. -Names that stay in main are imported lazily inside the functions that use them -(call-time resolution keeps ``hermes_cli.main.`` patches effective and -avoids an import cycle). +Split out of ``hermes_cli/main.py``. Names that still live in main (``PROJECT_ROOT``, ...) +are imported lazily inside the functions that use them (avoids an import cycle). """ import sys diff --git a/hermes_cli/main_dashboard.py b/hermes_cli/main_dashboard.py index 14db9b4022..e482d74e83 100644 --- a/hermes_cli/main_dashboard.py +++ b/hermes_cli/main_dashboard.py @@ -1,10 +1,7 @@ """Dashboard/serve support: managed-service restart (systemd/respawn), status/listening probes, SSH session token file, named-profile routing, web-dist resolution, update stdio hangup protection. -Split out of ``hermes_cli/main.py``; every moved name is re-imported there, so -``hermes_cli.main.`` keeps resolving (and monkeypatching) as before. -Names that stay in main are imported lazily inside the functions that use them -(call-time resolution keeps ``hermes_cli.main.`` patches effective and -avoids an import cycle). +Split out of ``hermes_cli/main.py``. Names that still live in main (``PROJECT_ROOT``, ...) +are imported lazily inside the functions that use them (avoids an import cycle). """ import contextlib @@ -23,8 +20,8 @@ _PRE_BUILD_HINT = " Pre-build first: npm install --workspace web && npm run bu def _find_stale_dashboard_pids(*, exclude_pids: set[int] | None = None) -> list[int]: """Return PIDs of stale ``dashboard``/``serve`` processes for update cleanup.""" - from hermes_cli.main import _self - return [pid for pid, _cmd in _self()._scan_dashboard_processes(exclude_pids=exclude_pids)] + from hermes_cli.dashboard_procs import _scan_dashboard_processes + return [pid for pid, _cmd in _scan_dashboard_processes(exclude_pids=exclude_pids)] def _parse_dashboard_runtime(command: str) -> tuple[str, str, int] | None: @@ -390,10 +387,10 @@ def _report_dashboard_status() -> int: Ledger-registered serves (profiled launches the argv scan can't match) surface via the spawn-ledger augmentation in _scan_dashboard_processes. See #81564. """ - from hermes_cli.main import _dashboard_listening, _self + from hermes_cli.dashboard_procs import _scan_dashboard_processes from gateway.status import _pid_exists live: list[tuple[int, str, str]] = [] - for pid, command in _self()._scan_dashboard_processes(): + for pid, command in _scan_dashboard_processes(): runtime = _parse_dashboard_runtime(command) if runtime is None: continue @@ -647,7 +644,6 @@ def _route_named_profile_dashboard( active_profile file). ``--isolated`` opts out; Desktop pool backends (HERMES_DESKTOP=1) stay per-profile. Returns normally when no routing applies. """ - from hermes_cli.main import _dashboard_listening try: from hermes_cli.profiles import get_active_profile_name _launch_profile = get_active_profile_name() @@ -724,7 +720,8 @@ def _resolve_dashboard_web_dist(args, _headless_backend: bool) -> None: (else the server serves 404s). --skip-build on the default location gets ONE recovery build; a caller-managed HERMES_WEB_DIST can't be populated. """ - from hermes_cli.main import PROJECT_ROOT, _build_web_ui + from hermes_cli.main import PROJECT_ROOT + from hermes_cli.main_web_build import _build_web_ui skip_build = getattr(args, "skip_build", False) if _headless_backend: os.environ["HERMES_SERVE_HEADLESS"] = "1" # set before web_server import diff --git a/hermes_cli/main_desktop.py b/hermes_cli/main_desktop.py index 1796cf525a..3d281d6992 100644 --- a/hermes_cli/main_desktop.py +++ b/hermes_cli/main_desktop.py @@ -1,10 +1,7 @@ """Desktop (Electron) app: build/stamp, stage-and-swap pack, exe integrity gate, macOS signing/TCC, Linux sandbox, launch (hermes gui/desktop). -Split out of ``hermes_cli/main.py``; every moved name is re-imported there, so -``hermes_cli.main.`` keeps resolving (and monkeypatching) as before. -Names that stay in main are imported lazily inside the functions that use them -(call-time resolution keeps ``hermes_cli.main.`` patches effective and -avoids an import cycle). +Split out of ``hermes_cli/main.py``. Names that still live in main (``PROJECT_ROOT``, ...) +are imported lazily inside the functions that use them (avoids an import cycle). """ import logging @@ -51,7 +48,6 @@ def _desktop_stamp_path() -> Path: def _renderer_bundle_dir(desktop_dir: Path, *, source_mode: bool) -> Optional[Path]: """The renderer ``dist`` a launch loads: ``apps/desktop/dist`` in source mode, else the ``app.asar.unpacked/dist`` copy (the only real directory, and the one an interrupted replace tears).""" - from hermes_cli.main import _desktop_packaged_executable if source_mode: return desktop_dir / "dist" @@ -100,7 +96,6 @@ def _renderer_bundle_torn(dist_dir: Path) -> bool: def _desktop_build_needed(desktop_dir: Path, project_root: Path, *, source_mode: bool) -> bool: """True when the desktop build output is stale, missing, torn, or built in the other mode.""" - from hermes_cli.main import _desktop_dist_exists, _desktop_packaged_executable, _desktop_stamp_path if source_mode: if not _desktop_dist_exists(desktop_dir): return True @@ -121,7 +116,6 @@ def _desktop_build_needed(desktop_dir: Path, project_root: Path, *, source_mode: def _write_desktop_build_stamp(project_root: Path, *, source_mode: bool) -> None: """Write the desktop build stamp after a successful build.""" - from hermes_cli.main import _desktop_stamp_path _write_build_stamp( _desktop_stamp_path(), "desktop", lambda: _compute_desktop_content_hash(project_root), sourceMode=source_mode) @@ -342,7 +336,6 @@ def _windows_native_machine() -> str: def _expected_windows_pe_machines() -> set: """PE machines this Windows host can load: ``GetMachineTypeAttributes``, else by name (AMD64 → x64+x86, ARM64 → ARM64+x64, x86 → x86). Unknown hosts get the full set so the gate can never brick launch.""" - from hermes_cli.main import _windows_native_machine if sys.platform == "win32": try: runnable = _windows_user_runnable_pe_machines() @@ -409,7 +402,6 @@ def _pe_machine_or_none(path: Path) -> Optional[int]: def _desktop_exe_integrity_error(path: Path) -> Optional[str]: """Why ``path`` cannot run on this Windows host, or None when it parses as a loadable PE.""" - from hermes_cli.main import _windows_native_machine try: machine = _parse_pe_machine(path) except ValueError as exc: @@ -461,7 +453,6 @@ def _ensure_desktop_exe_launchable(desktop_dir: Path, packaged_executable: Optio See #69179. """ - from hermes_cli.main import _desktop_stamp_path, _purge_electron_build_cache if packaged_executable is None or sys.platform != "win32": return packaged_executable, False @@ -523,7 +514,6 @@ def _purge_electron_build_cache(desktop_dir: Path, release_dir: Optional[Path] = ``release_dir`` points a stage-and-swap caller at its STAGING output so the live app is never touched. Never raises; empty result ⇒ nothing to retry. """ - from hermes_cli.main import _electron_download_cache_dirs removed: list[Path] = [] for cache_dir in _electron_download_cache_dirs(): @@ -591,7 +581,6 @@ def _electron_dist_ok(project_root: Path) -> bool: def _electron_pkg_staged_missing_dist(project_root: Path) -> bool: """electron staged (package.json + install.js) but dist missing — blocked postinstall.""" - from hermes_cli.main import _electron_dist_ok electron_dir = _electron_dir(project_root) return ( (electron_dir / "package.json").is_file() @@ -601,7 +590,6 @@ def _electron_pkg_staged_missing_dist(project_root: Path) -> bool: def _redownload_electron_dist(project_root: Path, env: dict, *, mirror: Optional[str] = None) -> bool: """Best-effort: run electron's install.js to populate dist/ (optional mirror).""" - from hermes_cli.main import _electron_dist_ok if _electron_dist_ok(project_root): return True @@ -630,7 +618,6 @@ def _redownload_electron_dist(project_root: Path, env: dict, *, mirror: Optional def _try_redownload_electron_dist(project_root: Path, env: dict) -> bool: """Canonical download, then fallback mirror unless the user pinned one.""" - from hermes_cli.main import _redownload_electron_dist if _redownload_electron_dist(project_root, env): return True if env.get("ELECTRON_MIRROR"): @@ -859,7 +846,6 @@ def _desktop_macos_relaunchable_fixup( dotenv load can't reverse it) or an intact Developer ID signature. ``release_dir`` signs the STAGED bundle before promotion. Never raises. """ - from hermes_cli.main import _desktop_macos_has_valid_real_signature, _desktop_macos_local_codesign, _desktop_macos_local_signing_identity if sys.platform != "darwin": return True if publisher_signing_configured is None: @@ -992,7 +978,7 @@ def _desktop_macos_setup_tcc_identity(identity: str = "Hermes Local Signing") -> ``desktop.macos_signing_identity`` at it and re-sign the packaged app. TCC grants follow the signing identity, so a certificate-anchored one is stable across rebuilds (the yabai/skhd mechanism). Idempotent; never raises.""" - from hermes_cli.main import PROJECT_ROOT, _desktop_macos_relaunchable_fixup, _desktop_packaged_executable + from hermes_cli.main import PROJECT_ROOT if sys.platform != "darwin": print(" (--setup-tcc-identity is macOS-only; skipping)") return False @@ -1125,7 +1111,6 @@ def _desktop_linux_sandbox_helper_is_regular_file(packaged_executable: Path) -> def _desktop_linux_sandbox_fixup(packaged_executable: Path) -> bool: """Configure Electron's Linux SUID sandbox helper when required.""" - from hermes_cli.main import _desktop_linux_userns_sandbox_available if sys.platform != "linux": return True @@ -1254,7 +1239,8 @@ def _register_linux_desktop_entry() -> None: def _install_desktop_workspace_deps(npm: str, env: dict) -> None: """npm-install the desktop workspace; exits on a failure that isn't a repairable missing Electron dist.""" - from hermes_cli.main import PROJECT_ROOT, _run_npm_install_deterministic + from hermes_cli.main import PROJECT_ROOT + from hermes_cli.main_web_build import _run_npm_install_deterministic from hermes_constants import with_hermes_node_path print("→ Installing desktop workspace dependencies...") # Managed Node on PATH so npm's child scripts that shell out to bare `node` @@ -1286,7 +1272,7 @@ def _run_desktop_pack_with_recovery( (e.g. macOS signing) leaves it in place and a redownload retry would only repeat the same slow failure. """ - from hermes_cli.main import PROJECT_ROOT, _electron_dist_ok, _purge_electron_build_cache, _redownload_electron_dist, _stop_desktop_processes_locking_build + from hermes_cli.main import PROJECT_ROOT def _staged_exe() -> Optional[Path]: return _desktop_packaged_executable_in(staging_dir) if staging_dir else None @@ -1332,7 +1318,6 @@ def _run_desktop_pack_with_recovery( def _promote_staged_desktop_app(desktop_dir: Path, staging_dir: Path) -> Path: """Sign + integrity-gate the STAGED pack, then swap it over the live app. Exits (live app kept) on failure.""" - from hermes_cli.main import _desktop_macos_relaunchable_fixup staged_executable = _desktop_packaged_executable_in(staging_dir) # Locally-built apps are ad-hoc signed; make them relaunchable after an # in-place self-update. Signs the STAGED bundle so the live app is never @@ -1360,7 +1345,7 @@ def _promote_staged_desktop_app(desktop_dir: Path, staging_dir: Path) -> Path: def _build_desktop_app(desktop_dir: Path, *, source_mode: bool, npm: str, env: dict) -> Optional[Path]: """npm-install + build the desktop app, stage-and-swapping the packaged tree. Returns the new packaged exe (None in source mode). Exits on unrecoverable failure with the previous app kept.""" - from hermes_cli.main import PROJECT_ROOT, _desktop_packaged_executable, _stop_desktop_processes_locking_build, _write_desktop_build_stamp + from hermes_cli.main import PROJECT_ROOT _install_desktop_workspace_deps(npm, env) build_label = "source build" if source_mode else "packaged app" @@ -1413,7 +1398,6 @@ def _build_desktop_app(desktop_dir: Path, *, source_mode: bool, npm: str, env: d def _desktop_launch_env(args: argparse.Namespace) -> tuple[dict, list[str]]: """Electron child env + config-supplied extra flags. ``desktop.*`` config is bridged to env vars Electron already reads; an explicit env var wins over config (and over keychain detection).""" - from hermes_cli.main import _desktop_launch_options, _detect_linux_password_store from hermes_constants import with_hermes_node_path # with_hermes_node_path() copies os.environ when called with no arg. env = with_hermes_node_path() @@ -1448,7 +1432,6 @@ def _check_desktop_skip_build( desktop_dir: Path, project_root: Path, *, source_mode: bool, packaged_executable: Optional[Path] ) -> None: """Validate the pre-built artifact ``--skip-build`` promised; exit with a hint when it's missing.""" - from hermes_cli.main import _desktop_dist_exists if source_mode: if not _desktop_dist_exists(desktop_dir): print(f"✗ --skip-build --source was passed but no desktop dist found at: {desktop_dir / 'dist'}") @@ -1472,7 +1455,6 @@ def _check_desktop_skip_build( def _packaged_desktop_launch_command(packaged_executable: Path) -> list[str]: """``[exe, *sandbox flags]`` after the Linux sandbox fixup; exits when the sandbox can't be configured.""" - from hermes_cli.main import _desktop_linux_sandbox_fixup launch_command = [str(packaged_executable)] if not _desktop_linux_sandbox_fixup(packaged_executable): if _desktop_linux_needs_no_sandbox() and _desktop_linux_sandbox_helper_is_regular_file(packaged_executable): @@ -1487,7 +1469,8 @@ def _packaged_desktop_launch_command(packaged_executable: Path) -> list[str]: def cmd_gui(args: argparse.Namespace): """Build and launch the native Electron desktop GUI.""" - from hermes_cli.main import PROJECT_ROOT, _desktop_build_needed, _desktop_dist_exists, _desktop_macos_setup_tcc_identity, _desktop_packaged_executable, _register_linux_desktop_entry, _resolve_node_runtime_npm + from hermes_cli.main import PROJECT_ROOT + from hermes_cli.main_install_repair import _resolve_node_runtime_npm desktop_dir = PROJECT_ROOT / "apps" / "desktop" if not (desktop_dir / "package.json").exists(): print(f"Desktop GUI source not found at: {desktop_dir}") diff --git a/hermes_cli/main_install_repair.py b/hermes_cli/main_install_repair.py index 7cccbc589a..32b6349963 100644 --- a/hermes_cli/main_install_repair.py +++ b/hermes_cli/main_install_repair.py @@ -1,8 +1,7 @@ """Install/update recovery: interrupted-install markers, lazy-refresh repair, Windows shim quarantine, dependency verification. -Split out of ``hermes_cli/main.py``; every moved name is re-imported there so -``hermes_cli.main.`` keeps resolving (and monkeypatching). Names that stay in main -are imported lazily inside the functions that use them (patch-effective, no import cycle). +Split out of ``hermes_cli/main.py``. Names that still live in main (``PROJECT_ROOT``, ...) +are imported lazily inside the functions that use them (avoids an import cycle). """ import contextlib @@ -120,7 +119,6 @@ def _clear_marker_file(path: Path, *, label: str) -> None: def _clear_update_incomplete_marker() -> None: """Remove the interrupted core-install breadcrumb. Never raises.""" - from hermes_cli.main import _update_marker_path _clear_marker_file(_update_marker_path(), label="update-incomplete") @@ -184,7 +182,7 @@ def _recover_from_interrupted_install() -> None: Concurrent launches race on the shared venv, so an ``O_EXCL`` lockfile lets one process recover while the others skip. """ - from hermes_cli.main import PROJECT_ROOT, _clear_update_incomplete_marker, _pytest_owns_live_checkout, _recover_core_update_marker_locked, _update_marker_path + from hermes_cli.main import PROJECT_ROOT if _pytest_owns_live_checkout(PROJECT_ROOT): return lazy_marker = _lazy_refresh_marker_path().exists() @@ -214,7 +212,6 @@ def _recover_from_interrupted_install() -> None: def _recover_lazy_refresh_marker_locked() -> None: """Heal ``.lazy-refresh-incomplete`` via confirmed import-probe repair.""" - from hermes_cli.main import _default_venv_install_target, _repair_venv_via_import_probes print( "⚠ A previous lazy-backend refresh may have left the venv unhealthy — " "running import-based package repair...") @@ -245,7 +242,7 @@ def _recover_core_update_marker_locked() -> None: install finished — a missing dep outside that probe set would look healthy and clear the breadcrumb too early. """ - from hermes_cli.main import PROJECT_ROOT, _clear_update_incomplete_marker, _default_venv_install_target, _repair_venv_via_import_probes + from hermes_cli.main import PROJECT_ROOT print( "⚠ A previous `hermes update` was interrupted mid-install — " "finishing dependency installation now...") @@ -313,7 +310,6 @@ def _windows_shim_in_process_chain() -> Path | None: See #88838, #89599. """ - from hermes_cli.main import _hermes_exe_shims, _is_windows, _venv_scripts_dir if not _is_windows(): return None scripts_dir = _venv_scripts_dir() @@ -353,7 +349,6 @@ def _windows_shim_in_process_chain() -> Path | None: def _windows_running_hermes_launcher_locked() -> bool: """True when a venv ``hermes*.exe`` shim is this process or an ancestor (best-effort).""" - from hermes_cli.main import _windows_shim_in_process_chain return _windows_shim_in_process_chain() is not None @@ -388,7 +383,6 @@ def _reexec_dependency_sync_off_windows_shim() -> bool: holds it open without ``FILE_SHARE_DELETE`` for the whole command, so the quarantine rename is refused and uv fails to replace it with os error 32 (#88838, #89599). """ - from hermes_cli.main import _UPDATE_REEXEC_ENV, _windows_shim_in_process_chain if os.environ.get(_UPDATE_REEXEC_ENV) == "1": return False shim = _windows_shim_in_process_chain() @@ -418,7 +412,7 @@ def _reexec_dependency_sync_off_windows_shim() -> bool: def _default_venv_install_target() -> tuple[list[str], dict[str, str] | None]: """Return ``(install_cmd_prefix, env)`` for the project venv when possible.""" - from hermes_cli.main import PROJECT_ROOT, _is_termux_env + from hermes_cli.main import PROJECT_ROOT try: from hermes_cli.managed_uv import ensure_uv uv_bin = ensure_uv() @@ -489,7 +483,7 @@ def _is_windows() -> bool: def _venv_scripts_dir() -> Path | None: """Return the venv Scripts directory if we're running inside the project venv.""" - from hermes_cli.main import PROJECT_ROOT, _is_windows + from hermes_cli.main import PROJECT_ROOT from hermes_constants import project_venv_dir, venv_bin_dir venv_dir = project_venv_dir(PROJECT_ROOT) if venv_dir is None: @@ -501,7 +495,6 @@ def _venv_scripts_dir() -> Path | None: def _hermes_exe_shims(scripts_dir: Path) -> list[Path]: """Entry-point shims uv may rewrite during ``pip install -e .`` — Windows .exe launchers only; POSIX shims are plain scripts replaced atomically.""" - from hermes_cli.main import _is_windows if not _is_windows(): return [] names = set(_load_console_script_names()) or {"hermes", "hermes-agent", "hermes-acp"} @@ -543,7 +536,6 @@ def _quarantine_running_hermes_exe( See #87331. """ - from hermes_cli.main import _hermes_exe_shims, _is_windows moved: list[tuple[Path, Path]] = [] if not _is_windows(): return moved @@ -608,7 +600,6 @@ def _cleanup_pending_shim_renames(scripts_dir: Path) -> int: """Drop reboot renames older Hermes versions queued for our shims: ``MOVEFILE_DELAY_UNTIL_REBOOT`` fallbacks outlive the update that queued them and move away whatever sits at the shim path at next boot — even a shim a later repair just wrote. Needs elevation; a no-op otherwise.""" - from hermes_cli.main import _filter_pending_shim_renames, _hermes_exe_shims, _is_windows if not _is_windows(): return 0 try: @@ -671,7 +662,6 @@ def _run_quarantined_install( See #87331. """ - from hermes_cli.main import ShimQuarantineError, _quarantine_running_hermes_exe, _restore_quarantined_exes, _run_install_with_heartbeat moved: list[tuple[Path, Path]] = [] failed: list[str] = [] if scripts_dir is not None: @@ -721,7 +711,6 @@ def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None: Deleting it converts a one-rename recovery into a full reinstall. See #75584. """ - from hermes_cli.main import _QUARANTINE_GRACE_SECONDS, _cleanup_pending_shim_renames, _is_windows, _quarantine_stamp_ms, _venv_scripts_dir if not _is_windows(): return scripts_dir = scripts_dir if scripts_dir is not None else _venv_scripts_dir() @@ -766,7 +755,6 @@ def _run_package_only_install(cmd: list[str], *, env: dict[str, str] | None = No See #57828. """ - from hermes_cli.main import _run_install_with_heartbeat _run_install_with_heartbeat(cmd, env=env) @@ -800,7 +788,6 @@ def _detect_broken_lazy_refresh_imports( """Probe lazy-refresh packages via real imports: ``[]`` all clean, ``[dist, ...]`` failures, ``None`` when the probe could not run (no venv Python, subprocess failure, non-zero exit) — *indeterminate*, not healthy.""" - from hermes_cli.main import _resolve_install_target_python venv_python = _resolve_install_target_python(install_cmd_prefix, env) if venv_python is None: return None @@ -849,7 +836,6 @@ def _repair_broken_lazy_refresh_imports( install_cmd_prefix: list[str], packages: list[str], *, env: dict[str, str] | None = None ) -> bool: """Force-reinstall ``packages`` and re-probe imports. Never raises.""" - from hermes_cli.main import _detect_broken_lazy_refresh_imports, _run_package_only_install if not packages: return True specs = _lazy_refresh_repair_specs(packages) @@ -872,7 +858,6 @@ def _repair_venv_via_import_probes( See #57828. """ - from hermes_cli.main import _detect_broken_lazy_refresh_imports, _repair_broken_lazy_refresh_imports broken = _detect_broken_lazy_refresh_imports(install_cmd_prefix, env=env) if broken is None: print(" ⚠ Import probes unavailable — cannot confirm venv package health.") @@ -919,7 +904,6 @@ def _interpreter_scripts_dir() -> Path | None: See #76105. """ - from hermes_cli.main import _is_windows from hermes_constants import venv_bin_dir exe = Path(sys.executable) # sys.executable lives IN the bin/Scripts dir; parent.parent is the env root. @@ -944,7 +928,6 @@ def _install_python_dependencies_with_optional_fallback( installs (#71510 fixed the ZIP path, #83335 fixed lazy-deps; this closes the shared helper for the remaining callers). """ - from hermes_cli.main import _insert_python_pin, _interpreter_scripts_dir, _is_windows, _load_installable_optional_extras, _run_quarantined_install, _venv_scripts_dir, _verify_console_scripts_installed, _verify_core_dependencies_installed scripts_dir = _venv_scripts_dir() if _is_windows() else None # Only uv needs the explicit pin; pip resolves the target from sys.executable itself. @@ -1015,7 +998,6 @@ def _verify_console_scripts_installed( The symptom is ``hermes-agent.exe`` and ``hermes-acp.exe`` present but ``hermes.exe`` missing, so ``hermes`` drops off PATH even though the install reported success (issue #52931). """ - from hermes_cli.main import _is_windows, _run_quarantined_install, _venv_scripts_dir if not _is_windows(): return scripts_dir = _venv_scripts_dir() @@ -1079,7 +1061,6 @@ def _verify_core_dependencies_installed( install. The final state is a warning, not a hard failure, so one broken-on-PyPI dep can't block an otherwise-successful update — but the partial install is visible where it happened. """ - from hermes_cli.main import _is_windows, _resolve_install_target_python, _run_install_with_heartbeat, _run_quarantined_install, _venv_scripts_dir project = _pyproject_project("dep verification: failed to read pyproject.toml: %s") if project is None: return @@ -1143,7 +1124,6 @@ def _resolve_install_target_python( install_cmd_prefix: list[str], env: dict[str, str] | None) -> Path | None: """Python interpreter the install targeted: ``VIRTUAL_ENV`` from ``env`` for the ``[uv, pip]`` shape, else ``install_cmd_prefix[0]`` for ``[sys.executable, -m, pip]``.""" - from hermes_cli.main import _is_windows if env and "VIRTUAL_ENV" in env: from hermes_constants import venv_python_path candidate = venv_python_path(Path(env["VIRTUAL_ENV"]), windows=_is_windows()) @@ -1179,7 +1159,6 @@ def _resolve_node_runtime_npm() -> str | None: On WSL/Linux ``shutil.which("npm")`` may resolve a Windows npm exposed through PATH interop. See #30271. """ - from hermes_cli.main import _is_windows from hermes_constants import find_node_executable npm = find_node_executable("npm") if _is_windows(): diff --git a/hermes_cli/main_provider_setup.py b/hermes_cli/main_provider_setup.py index a8a89fb974..9cdd3d8c89 100644 --- a/hermes_cli/main_provider_setup.py +++ b/hermes_cli/main_provider_setup.py @@ -1,8 +1,7 @@ """Provider setup wizard helpers: provider picker, custom-provider save/remove, auxiliary-model routing menu, API-key/reasoning prompts, Anthropic OAuth. -Split out of ``hermes_cli/main.py``, which re-imports every name (``hermes_cli.main.`` keeps -resolving and monkeypatching). Names that stay in main are imported lazily at call time. +Split out of ``hermes_cli/main.py``. Names that still live in main are imported lazily at call time. """ import contextlib @@ -79,7 +78,6 @@ _DELEGATION_TASK_DESC = "subagent model (delegate_task)" def _all_aux_tasks() -> list[tuple[str, str, str]]: """Built-in aux tasks (in order) followed by plugin-registered ones (:meth:`hermes_cli.plugins.PluginContext.register_auxiliary_task`).""" - from hermes_cli.main import _AUX_TASKS tasks = list(_AUX_TASKS) # Plugin discovery failure must not break the aux config UI. with contextlib.suppress(Exception): @@ -176,7 +174,6 @@ def _reset_aux_to_auto() -> int: def _aux_config_menu() -> None: """Top-level auxiliary-model picker; loops until the user picks "Back".""" - from hermes_cli.main import _aux_select_for_task, _prompt_provider_choice from hermes_cli.config import load_config while True: cfg = load_config() @@ -214,7 +211,6 @@ def _aux_config_menu() -> None: def _aux_select_for_task(task: str) -> None: """Pick a provider + model for one aux task and persist it. Rows come from ``build_aux_picker_rows()`` (shared substrate): only already-configured providers appear.""" - from hermes_cli.main import _prompt_provider_choice from hermes_cli.config import load_config from hermes_cli.inventory import build_aux_picker_rows, format_aux_picker_entries task_cfg = _aux_task_cfg(load_config(), task) diff --git a/hermes_cli/main_tui_launch.py b/hermes_cli/main_tui_launch.py index f7f0c78358..2a3d238128 100644 --- a/hermes_cli/main_tui_launch.py +++ b/hermes_cli/main_tui_launch.py @@ -1,10 +1,7 @@ """TUI (ui-tui) launcher: node/npm bootstrap, workspace/rebuild checks, argv/env assembly. -Split out of ``hermes_cli/main.py``; every moved name is re-imported there, so -``hermes_cli.main.`` keeps resolving (and monkeypatching) as before. -Names that stay in main are imported lazily inside the functions that use them -(call-time resolution keeps ``hermes_cli.main.`` patches effective and -avoids an import cycle). +Split out of ``hermes_cli/main.py``. Names that still live in main (``PROJECT_ROOT``, ...) +are imported lazily inside the functions that use them (avoids an import cycle). """ import logging @@ -520,7 +517,7 @@ def _install_tui_dependencies(tui_dir: Path, *, termux_startup: bool) -> None: def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: """TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR prebuilt or esbuild).""" - from hermes_cli.main import _ensure_tui_node, _find_bundled_tui, _is_termux_startup_environment, _tui_need_npm_install, _tui_need_rebuild + from hermes_cli.main import _is_termux_startup_environment _ensure_tui_node() # Footgun: --dev against a prebuilt bundle that has no source/node_modules. @@ -643,7 +640,6 @@ def _resolve_tui_heap_mb(default_mb: int = 8192) -> int: """V8 ``--max-old-space-size`` (MB) that fits the container: ``default_mb`` when unconstrained, else 75% of the cgroup limit (headroom for non-heap RSS + the gateway child), floored at 1536MB when the container is > 2GB (below that V8 GC-thrashes).""" - from hermes_cli.main import _read_cgroup_memory_limit limit = _read_cgroup_memory_limit() if not limit: return default_mb @@ -720,7 +716,7 @@ def _launch_tui( image: Optional[str] = None, worktree: bool = False, checkpoints: bool = False, pass_session_id: bool = False, max_turns: Optional[int] = None, accept_hooks: bool = False): """Replace current process with the TUI.""" - from hermes_cli.main import PROJECT_ROOT, _apply_tui_python_env, _make_tui_argv, _resolve_tui_heap_mb + from hermes_cli.main import PROJECT_ROOT tui_dir = PROJECT_ROOT / "ui-tui" import tempfile diff --git a/hermes_cli/main_web_build.py b/hermes_cli/main_web_build.py index d085b94d43..41cdbdad2c 100644 --- a/hermes_cli/main_web_build.py +++ b/hermes_cli/main_web_build.py @@ -1,10 +1,7 @@ """Web UI (dashboard frontend) build: content-hash stamps, npm install/build with idle timeout, bytecode sweep. -Split out of ``hermes_cli/main.py``; every moved name is re-imported there, so -``hermes_cli.main.`` keeps resolving (and monkeypatching) as before. -Names that stay in main are imported lazily inside the functions that use them -(call-time resolution keeps ``hermes_cli.main.`` patches effective and -avoids an import cycle). +Split out of ``hermes_cli/main.py``. Names that still live in main (``PROJECT_ROOT``, ...) +are imported lazily inside the functions that use them (avoids an import cycle). """ import logging @@ -60,7 +57,7 @@ def _sweep_stale_bytecode_if_checkout_changed() -> None: ``__pycache__`` retains bytecode from the previous revision, and a later process trusts the stale ``.pyc`` instead of the fresh source. """ - from hermes_cli.main import PROJECT_ROOT, _clear_bytecode_cache, _read_git_revision_fingerprint, _record_bytecode_fingerprint + from hermes_cli.main import PROJECT_ROOT, _clear_bytecode_cache, _read_git_revision_fingerprint try: fingerprint = _read_git_revision_fingerprint(PROJECT_ROOT) if not fingerprint: @@ -479,7 +476,7 @@ def _do_build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: soft warning (used by ``hermes web``). Returns True when the build succeeded or was skipped (no package.json / up to date / stale dist served as fallback). """ - from hermes_cli.main import _resolve_node_runtime_npm, _run_npm_install_deterministic, _run_with_idle_timeout, _web_ui_build_needed, _write_web_ui_build_stamp + from hermes_cli.main_install_repair import _resolve_node_runtime_npm if not (web_dir / "package.json").exists() or not _web_ui_build_needed(web_dir): return True diff --git a/hermes_cli/model_setup_flows.py b/hermes_cli/model_setup_flows.py index 90a02016ae..00df4ce120 100644 --- a/hermes_cli/model_setup_flows.py +++ b/hermes_cli/model_setup_flows.py @@ -77,7 +77,7 @@ def _model_flow_openrouter(config, current_model=""): def _model_flow_ai_gateway(config, current_model=""): """Vercel AI Gateway provider: ensure API key, then pick model with pricing.""" from hermes_constants import AI_GATEWAY_BASE_URL - from hermes_cli.main import _prompt_api_key + from hermes_cli.main_provider_setup import _prompt_api_key from hermes_cli.auth import PROVIDER_REGISTRY, _prompt_model_selection from hermes_cli.config import get_env_value pconfig = PROVIDER_REGISTRY["ai-gateway"] @@ -396,7 +396,7 @@ def _model_flow_xai_oauth(_config, current_model="", *, args=None): def _model_flow_qwen_oauth(_config, current_model=""): """Qwen OAuth provider: reuse local Qwen CLI login, then pick model.""" - from hermes_cli.main import _DEFAULT_QWEN_PORTAL_MODELS + from hermes_cli.main_provider_setup import _DEFAULT_QWEN_PORTAL_MODELS from hermes_cli.auth import ( get_qwen_auth_status, resolve_qwen_runtime_credentials, _prompt_model_selection, DEFAULT_QWEN_BASE_URL) from hermes_cli.models import fetch_api_models @@ -522,7 +522,7 @@ def _copilot_obtain_token() -> bool: def _model_flow_copilot(config, current_model=""): """GitHub Copilot flow using env vars, gh CLI, or OAuth device code.""" - from hermes_cli.main import _prompt_reasoning_effort_selection + from hermes_cli.main_provider_setup import _prompt_reasoning_effort_selection from hermes_cli.setup import _current_reasoning_effort, _set_reasoning_effort from hermes_cli.auth import PROVIDER_REGISTRY, resolve_api_key_provider_credentials from hermes_cli.config import load_config @@ -651,7 +651,7 @@ def _model_flow_kimi(config, current_model=""): def _model_flow_stepfun(config, current_model=""): """StepFun Step Plan flow with region-specific endpoints.""" - from hermes_cli.main import _infer_stepfun_region, _prompt_provider_choice, _stepfun_base_url_for_region + from hermes_cli.main_provider_setup import _infer_stepfun_region, _prompt_provider_choice, _stepfun_base_url_for_region from hermes_cli.auth import PROVIDER_REGISTRY from hermes_cli.config import save_env_value from hermes_cli.models import _PROVIDER_MODELS, fetch_api_models @@ -762,7 +762,7 @@ def _select_zai_endpoint(current_base: str) -> str: """Picker for the official Z.AI endpoints (``ZAI_ENDPOINTS`` in ``hermes_cli.auth``, kept in sync with the probe list) plus a custom-proxy option. Returns the selected base URL; *current_base* on cancel/error.""" - from hermes_cli.main import _prompt_provider_choice + from hermes_cli.main_provider_setup import _prompt_provider_choice from hermes_cli.auth import ZAI_ENDPOINTS options = [(label, url) for _, url, _, label in ZAI_ENDPOINTS] normalized_current = (current_base or "").strip().rstrip("/") @@ -970,7 +970,7 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): def _anthropic_authenticate() -> bool: """Interactive Anthropic auth (OAuth subscription or API key). False = flow must stop.""" - from hermes_cli.main import _run_anthropic_oauth_flow + from hermes_cli.main_provider_setup import _run_anthropic_oauth_flow from hermes_cli.config import save_env_value, save_anthropic_api_key _say("", " Choose authentication method:", "", " 1. Claude Pro/Max subscription (OAuth login)", " 2. Anthropic API key (pay-per-token)", " 3. Cancel", "") diff --git a/hermes_cli/model_setup_flows_common.py b/hermes_cli/model_setup_flows_common.py index 8a655a890e..f0bd1bb17c 100644 --- a/hermes_cli/model_setup_flows_common.py +++ b/hermes_cli/model_setup_flows_common.py @@ -2,7 +2,7 @@ persist, confirmation — one copy of each step. Prompt strings and the config keys written (and their insertion order = config.yaml key order) are behavior; keep them byte-identical. -hermes_cli.main / auth / config imports are lazy on purpose: main.py re-imports the flows (import +main_provider_setup / auth / config imports are lazy on purpose: main.py imports the flows (import cycle) and tests patch ``hermes_cli.config.load_config`` etc. at call time. """ @@ -51,7 +51,7 @@ def _ensure_flow_api_key(provider_id: str, pconfig, *, missing_hint=()) -> tuple Returns ``(existing_key, resolved_key, abort)``. """ - from hermes_cli.main import _prompt_api_key + from hermes_cli.main_provider_setup import _prompt_api_key existing_key, existing_source = _existing_api_key_for_model_flow(provider_id, pconfig) if not existing_key: for line in missing_hint: diff --git a/hermes_cli/model_setup_flows_custom.py b/hermes_cli/model_setup_flows_custom.py index 91d101b906..7c5177c3ea 100644 --- a/hermes_cli/model_setup_flows_custom.py +++ b/hermes_cli/model_setup_flows_custom.py @@ -74,7 +74,7 @@ def _pick_detected_model(detected_models: list) -> str: def _model_flow_custom(config): """Custom endpoint: collect URL, API key, and model name; also saved to ``custom_providers`` so it appears in the provider menu on subsequent runs.""" - from hermes_cli.main import _auto_provider_name, _prompt_custom_api_mode_selection, _save_custom_provider + from hermes_cli.main_provider_setup import _auto_provider_name, _prompt_custom_api_mode_selection, _save_custom_provider from hermes_cli.auth import _save_model_choice, deactivate_provider from hermes_cli.config import custom_endpoint_key_env, get_env_value, save_env_value from hermes_cli.secret_prompt import masked_secret_prompt @@ -290,7 +290,7 @@ def _model_flow_named_custom(config, provider_info): """Named custom provider from ``custom_providers`` / ``providers.``: probes the model catalog (native ``/api/tags`` for endpoints conservatively identified as Ollama); a previously saved model is pre-selected and is the fallback when probing fails.""" - from hermes_cli.main import _custom_provider_api_key_config_value, _custom_provider_base_url_config_value, _save_custom_provider + from hermes_cli.main_provider_setup import _custom_provider_api_key_config_value, _custom_provider_base_url_config_value, _save_custom_provider from hermes_cli.auth import _save_model_choice from hermes_cli.config import load_config, save_config from hermes_cli.model_switch import _entry_models_discovered, _models_config_is_allowlist diff --git a/hermes_cli/profile_cmd.py b/hermes_cli/profile_cmd.py index 62759140ee..e9eb16180e 100644 --- a/hermes_cli/profile_cmd.py +++ b/hermes_cli/profile_cmd.py @@ -1,8 +1,7 @@ """``hermes profile`` command — one handler per action, dispatched by ``PROFILE_ACTIONS``. -``hermes_cli.main`` re-exports ``cmd_profile`` so existing callers and monkeypatches on -``hermes_cli.main.cmd_profile`` keep working. Imports from ``hermes_cli.profiles`` stay -lazy (inside each handler) so tests can monkeypatch the module attributes. +Imports from ``hermes_cli.profiles`` stay lazy (inside each handler) so tests can monkeypatch +the module attributes. """ from __future__ import annotations diff --git a/hermes_cli/setup_quick.py b/hermes_cli/setup_quick.py index 5df4325550..9f282af1a7 100644 --- a/hermes_cli/setup_quick.py +++ b/hermes_cli/setup_quick.py @@ -41,7 +41,7 @@ def _run_nous_flow(config: dict, *, context: str, cancel_exc: tuple, cancel_line message is already printed).""" from hermes_cli.setup import _info try: - from hermes_cli.main import _model_flow_nous + from hermes_cli.model_setup_flows import _model_flow_nous _model_flow_nous(config) return True except cancel_exc: diff --git a/hermes_cli/subcommands/sessions.py b/hermes_cli/subcommands/sessions.py index 3a4c9b573b..8782c2e051 100644 --- a/hermes_cli/subcommands/sessions.py +++ b/hermes_cli/subcommands/sessions.py @@ -261,7 +261,7 @@ def build_sessions_parser(subparsers, *, cmd_sessions: Callable) -> None: # cmd_sessions lives in hermes_cli/sessions_cmd.py; the parser is threaded # in because the fallthrough branch calls sessions_parser.print_help(). # main() injects a lazy indirection so sessions_cmd is only imported when - # the subcommand runs and monkeypatches on hermes_cli.main.cmd_sessions work. + # the subcommand runs. def _dispatch_sessions(_args, *, sessions_parser=sessions_parser): return cmd_sessions(_args, sessions_parser=sessions_parser) diff --git a/tests/agent/test_plugin_llm_task_routing.py b/tests/agent/test_plugin_llm_task_routing.py index a4682d0463..7dde600d0e 100644 --- a/tests/agent/test_plugin_llm_task_routing.py +++ b/tests/agent/test_plugin_llm_task_routing.py @@ -87,7 +87,7 @@ def _set_registry(monkeypatch, entries: List[Dict[str, Any]]) -> None: def _set_builtins(monkeypatch, keys: List[str]) -> None: monkeypatch.setattr( - "hermes_cli.main._AUX_TASKS", [(k, k.title(), "") for k in keys] + "hermes_cli.main_provider_setup._AUX_TASKS", [(k, k.title(), "") for k in keys] ) diff --git a/tests/cli/test_cli_insights_command.py b/tests/cli/test_cli_insights_command.py index e6cd0ed2eb..dea3e094a2 100644 --- a/tests/cli/test_cli_insights_command.py +++ b/tests/cli/test_cli_insights_command.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock, patch from types import SimpleNamespace from cli import HermesCLI -from hermes_cli.main import cmd_insights +from hermes_cli.main_agent_cmds import cmd_insights class _InsightsEngineStub: diff --git a/tests/cli/test_cli_provider_resolution.py b/tests/cli/test_cli_provider_resolution.py index 46336f95cc..ab8c7a0a8a 100644 --- a/tests/cli/test_cli_provider_resolution.py +++ b/tests/cli/test_cli_provider_resolution.py @@ -448,7 +448,7 @@ def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys): monkeypatch.setattr("hermes_cli.config.save_env_value", lambda key, value: saved_env.__setitem__(key, value)) monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: saved_env.__setitem__("MODEL", model)) monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda: None) - monkeypatch.setattr("hermes_cli.main._save_custom_provider", lambda *args, **kwargs: None) + monkeypatch.setattr("hermes_cli.main_provider_setup._save_custom_provider", lambda *args, **kwargs: None) monkeypatch.setattr( "hermes_cli.models.probe_api_models", lambda api_key, base_url: { @@ -510,7 +510,7 @@ def test_model_flow_custom_persists_selected_api_mode(monkeypatch): lambda key, value: saved_env.__setitem__(key, value), ) monkeypatch.setattr( - "hermes_cli.main._save_custom_provider", + "hermes_cli.main_provider_setup._save_custom_provider", lambda base_url, api_key="", model="", context_length=None, name=None, api_mode=None, key_env="": captured_provider.update( { "base_url": base_url, @@ -606,7 +606,7 @@ def test_cmd_model_forwards_nous_login_tls_options(monkeypatch): # --------------------------------------------------------------------------- def test_auto_provider_name_localhost(): - from hermes_cli.main import _auto_provider_name + from hermes_cli.main_provider_setup import _auto_provider_name assert _auto_provider_name("http://localhost:11434/v1") == "Local (localhost:11434)" assert _auto_provider_name("http://127.0.0.1:1234/v1") == "Local (127.0.0.1:1234)" @@ -618,7 +618,7 @@ def test_auto_provider_name_localhost(): def test_save_custom_provider_uses_provided_name(monkeypatch, tmp_path): """When a display name is passed, it should appear in the saved entry.""" import yaml - from hermes_cli.main import _save_custom_provider + from hermes_cli.main_provider_setup import _save_custom_provider cfg_path = tmp_path / "config.yaml" cfg_path.write_text(yaml.dump({})) @@ -640,7 +640,7 @@ def test_save_custom_provider_uses_provided_name(monkeypatch, tmp_path): def test_save_custom_provider_references_the_key_instead_of_inlining_it(monkeypatch, tmp_path): """With key_env set the entry must not carry the secret (#69449).""" import yaml - from hermes_cli.main import _save_custom_provider + from hermes_cli.main_provider_setup import _save_custom_provider cfg_path = tmp_path / "config.yaml" cfg_path.write_text(yaml.dump({})) diff --git a/tests/cli/test_update_command.py b/tests/cli/test_update_command.py index 6dd74ed0af..e788e8b036 100644 --- a/tests/cli/test_update_command.py +++ b/tests/cli/test_update_command.py @@ -7,7 +7,7 @@ Verifies that ``HermesCLI._handle_update_command`` correctly: - Cancels cleanly when ``_prompt_text_input_modal`` returns None (timeout / modal dismissed) -Also verifies that ``hermes_cli.main._launch_tui`` correctly handles exit +Also verifies that ``main_tui_launch._launch_tui`` correctly handles exit code 42 (the TUI's signal to trigger an update) by calling ``relaunch(["update"], preserve_inherited=False)`` from the Python wrapper side. The companion Vitest (``ui-tui/src/__tests__/createSlashHandler.test.ts``) @@ -23,6 +23,7 @@ from unittest.mock import patch import pytest from cli import HermesCLI +from hermes_cli import main_tui_launch def _bound(fn, instance): diff --git a/tests/gateway/test_update_streaming.py b/tests/gateway/test_update_streaming.py index 1e1134b7ad..8c58113bc6 100644 --- a/tests/gateway/test_update_streaming.py +++ b/tests/gateway/test_update_streaming.py @@ -78,7 +78,7 @@ class TestGatewayPrompt: thread.start() with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): - from hermes_cli.main import _gateway_prompt + from hermes_cli.update_cmd import _gateway_prompt result = _gateway_prompt("Restore? [Y/n]", "y", timeout=5.0) thread.join() @@ -98,7 +98,7 @@ class TestRestoreStashWithInputFn: def test_uses_input_fn_when_provided(self, tmp_path): """When input_fn is provided, it's called instead of input().""" - from hermes_cli.main import _restore_stashed_changes + from hermes_cli.update_cmd import _restore_stashed_changes captured_args = [] @@ -378,7 +378,7 @@ class TestCmdUpdateGatewayMode: def test_gateway_flag_enables_gateway_prompt_for_stash(self, tmp_path): """With --gateway, stash restore uses _gateway_prompt instead of input().""" - from hermes_cli.main import _restore_stashed_changes + from hermes_cli.update_cmd import _restore_stashed_changes # Use input_fn to verify the gateway path is taken calls = [] diff --git a/tests/hermes_cli/test_anthropic_model_flow_stale_oauth.py b/tests/hermes_cli/test_anthropic_model_flow_stale_oauth.py index 40526514cd..a0121c55b0 100644 --- a/tests/hermes_cli/test_anthropic_model_flow_stale_oauth.py +++ b/tests/hermes_cli/test_anthropic_model_flow_stale_oauth.py @@ -53,7 +53,7 @@ class TestStaleOAuthTokenDetection: monkeypatch.setattr("builtins.input", lambda _: "3") monkeypatch.setattr("hermes_cli.secret_prompt.masked_secret_prompt", lambda _: "") - from hermes_cli.main import _model_flow_anthropic + from hermes_cli.model_setup_flows import _model_flow_anthropic cfg = {} _model_flow_anthropic(cfg) @@ -92,7 +92,7 @@ class TestStaleOAuthTokenDetection: # Simulate user picks "1" (use existing) monkeypatch.setattr("builtins.input", lambda _: "1") - from hermes_cli.main import _model_flow_anthropic + from hermes_cli.model_setup_flows import _model_flow_anthropic cfg = {} _model_flow_anthropic(cfg) diff --git a/tests/hermes_cli/test_anthropic_oauth_flow.py b/tests/hermes_cli/test_anthropic_oauth_flow.py index 0213b05e95..30caa847d9 100644 --- a/tests/hermes_cli/test_anthropic_oauth_flow.py +++ b/tests/hermes_cli/test_anthropic_oauth_flow.py @@ -22,7 +22,7 @@ def test_run_anthropic_oauth_flow_prefers_claude_code_credentials(tmp_path, monk lambda creds: True, ) - from hermes_cli.main import _run_anthropic_oauth_flow + from hermes_cli.main_provider_setup import _run_anthropic_oauth_flow save_env_value("ANTHROPIC_TOKEN", "stale-env-token") assert _run_anthropic_oauth_flow(save_env_value) is True @@ -45,7 +45,7 @@ def test_run_anthropic_oauth_flow_manual_token_still_persists(tmp_path, monkeypa lambda _prompt="": "sk-ant-oat01-manual-token", ) - from hermes_cli.main import _run_anthropic_oauth_flow + from hermes_cli.main_provider_setup import _run_anthropic_oauth_flow assert _run_anthropic_oauth_flow(save_env_value) is True diff --git a/tests/hermes_cli/test_api_key_providers.py b/tests/hermes_cli/test_api_key_providers.py index 8b59e337f3..1dd8abb4dd 100644 --- a/tests/hermes_cli/test_api_key_providers.py +++ b/tests/hermes_cli/test_api_key_providers.py @@ -842,14 +842,14 @@ class TestKimiMoonshotModelListIsolation: """Moonshot (legacy) users must not see Coding Plan-only models.""" def test_moonshot_list_excludes_coding_plan_only_models(self): - from hermes_cli.main import _PROVIDER_MODELS + from hermes_cli.models import _PROVIDER_MODELS moonshot_models = _PROVIDER_MODELS["moonshot"] coding_plan_only = {"kimi-for-coding", "kimi-k2-thinking-turbo"} leaked = set(moonshot_models) & coding_plan_only assert not leaked, f"Moonshot list contains Coding Plan-only models: {leaked}" def test_moonshot_list_non_empty(self): - from hermes_cli.main import _PROVIDER_MODELS + from hermes_cli.models import _PROVIDER_MODELS assert len(_PROVIDER_MODELS["moonshot"]) >= 1 @@ -863,7 +863,7 @@ class TestHuggingFaceModels: def test_model_lists_match(self): """Model lists in main.py and models.py should be identical.""" - from hermes_cli.main import _PROVIDER_MODELS as main_models + from hermes_cli.models import _PROVIDER_MODELS as main_models from hermes_cli.models import _PROVIDER_MODELS as models_models assert main_models["huggingface"] == models_models["huggingface"] diff --git a/tests/hermes_cli/test_auth_qwen_provider.py b/tests/hermes_cli/test_auth_qwen_provider.py index 70cc16f2cb..42c7b4fb72 100644 --- a/tests/hermes_cli/test_auth_qwen_provider.py +++ b/tests/hermes_cli/test_auth_qwen_provider.py @@ -169,7 +169,7 @@ def test_get_qwen_auth_status_refreshes_expired_token(qwen_env): def test_model_flow_qwen_oauth_stale_token_shows_reauth_guidance(qwen_env, monkeypatch, capsys): - from hermes_cli.main import _model_flow_qwen_oauth + from hermes_cli.model_setup_flows import _model_flow_qwen_oauth expired_ms = int((time.time() - 3600) * 1000) tokens = _make_qwen_tokens(access_token="dead-at", expiry_date=expired_ms) diff --git a/tests/hermes_cli/test_authenticated_providers_exhausted_pool.py b/tests/hermes_cli/test_authenticated_providers_exhausted_pool.py index 0da911a22a..d9f7079ddd 100644 --- a/tests/hermes_cli/test_authenticated_providers_exhausted_pool.py +++ b/tests/hermes_cli/test_authenticated_providers_exhausted_pool.py @@ -8,6 +8,7 @@ the session provider (the "sticky provider fallback pollution" bug). """ import pytest +from hermes_cli import main_provider_setup class _FakePool: @@ -140,7 +141,7 @@ def test_aux_task_picker_requests_exhausted_pool_visibility(monkeypatch): monkeypatch.setattr("hermes_cli.config.load_config", lambda: {}) with pytest.raises(_StopPicker): - main._aux_select_for_task("compression") + main_provider_setup._aux_select_for_task("compression") assert recorded.get("for_picker") is True, ( "aux-task picker must pass for_picker=True so exhausted-pool providers " diff --git a/tests/hermes_cli/test_aux_config.py b/tests/hermes_cli/test_aux_config.py index a21c1f67bc..fb2b0f7668 100644 --- a/tests/hermes_cli/test_aux_config.py +++ b/tests/hermes_cli/test_aux_config.py @@ -15,14 +15,7 @@ from __future__ import annotations import pytest from hermes_cli.config import DEFAULT_CONFIG, load_config -from hermes_cli.main import ( - _AUX_TASKS, - _DELEGATION_TASK_KEY, - _delegation_cfg_as_task, - _format_aux_current, - _reset_aux_to_auto, - _save_aux_choice, -) +from hermes_cli.main_provider_setup import _AUX_TASKS, _DELEGATION_TASK_KEY, _delegation_cfg_as_task, _format_aux_current, _reset_aux_to_auto, _save_aux_choice # ── Default config ────────────────────────────────────────────────────────── diff --git a/tests/hermes_cli/test_aux_picker_inventory.py b/tests/hermes_cli/test_aux_picker_inventory.py index 6e2e73eb0a..27336409e9 100644 --- a/tests/hermes_cli/test_aux_picker_inventory.py +++ b/tests/hermes_cli/test_aux_picker_inventory.py @@ -23,6 +23,7 @@ from unittest.mock import patch import pytest import yaml +from hermes_cli import main_provider_setup CONFIG = { @@ -123,7 +124,6 @@ def test_aux_pickers_route_through_the_shared_substrate(configured_home): of user config the author didn't think about. Both pickers must reach the provider list only through ``build_aux_picker_rows``. """ - import hermes_cli.main as main import hermes_cli.tools_config as tools_config direct_calls = [] @@ -143,9 +143,9 @@ def test_aux_pickers_route_through_the_shared_substrate(configured_home): with ( patch("hermes_cli.model_switch.list_authenticated_providers", _direct), patch("hermes_cli.inventory.build_aux_picker_rows", _substrate), - patch("hermes_cli.main._prompt_provider_choice", return_value=None), + patch("hermes_cli.main_provider_setup._prompt_provider_choice", return_value=None), ): - main._aux_select_for_task("compression") + main_provider_setup._aux_select_for_task("compression") tools_config._configure_vision_provider_model({}, {}) assert len(substrate_calls) == 2, ( diff --git a/tests/hermes_cli/test_backup.py b/tests/hermes_cli/test_backup.py index 3850cce801..58b49c842a 100644 --- a/tests/hermes_cli/test_backup.py +++ b/tests/hermes_cli/test_backup.py @@ -1756,7 +1756,7 @@ class TestRunPreUpdateBackup: """pre_update_backup: off — an explicit opt-out disables the quick snapshot too (it previously ran unconditionally), with no output.""" self._set_mode(hermes_home, "off") - from hermes_cli.main import _run_pre_update_backup + from hermes_cli.update_cmd import _run_pre_update_backup snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=False)) out = capsys.readouterr().out assert snap_id is None @@ -1768,7 +1768,7 @@ class TestRunPreUpdateBackup: def test_config_full_mode(self, hermes_home, capsys): self._set_mode(hermes_home, "full") - from hermes_cli.main import _run_pre_update_backup + from hermes_cli.update_cmd import _run_pre_update_backup snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=False)) out = capsys.readouterr().out assert snap_id is not None diff --git a/tests/hermes_cli/test_bytecode_sweep.py b/tests/hermes_cli/test_bytecode_sweep.py index 402e93fc84..fb5ab29c2d 100644 --- a/tests/hermes_cli/test_bytecode_sweep.py +++ b/tests/hermes_cli/test_bytecode_sweep.py @@ -14,6 +14,7 @@ updaters). from pathlib import Path from hermes_cli import main as hermes_main +from hermes_cli import main_web_build def _make_repo(tmp_path: Path, sha: str = "a" * 40) -> Path: @@ -38,7 +39,7 @@ def test_sweep_clears_pycache_when_checkout_changed(monkeypatch, tmp_path): cache = _make_pycache(repo) monkeypatch.setattr(hermes_main, "PROJECT_ROOT", repo) # Stamp records a different (older) fingerprint. - (repo / hermes_main._BYTECODE_FINGERPRINT_FILE).write_text( + (repo / main_web_build._BYTECODE_FINGERPRINT_FILE).write_text( "git:refs/heads/main:" + "a" * 40, encoding="utf-8" ) @@ -46,7 +47,7 @@ def test_sweep_clears_pycache_when_checkout_changed(monkeypatch, tmp_path): assert not cache.exists() # Stamp updated to the current fingerprint. - recorded = (repo / hermes_main._BYTECODE_FINGERPRINT_FILE).read_text(encoding="utf-8") + recorded = (repo / main_web_build._BYTECODE_FINGERPRINT_FILE).read_text(encoding="utf-8") assert recorded.strip().endswith("b" * 40) diff --git a/tests/hermes_cli/test_checkout_mutation_guards.py b/tests/hermes_cli/test_checkout_mutation_guards.py index 18fcedae1e..86090ee73e 100644 --- a/tests/hermes_cli/test_checkout_mutation_guards.py +++ b/tests/hermes_cli/test_checkout_mutation_guards.py @@ -18,6 +18,8 @@ from __future__ import annotations from pathlib import Path import hermes_cli.main as main_mod +from hermes_cli import main_install_repair +from hermes_cli import update_cmd from hermes_cli import _early_recovery as er CHECKOUT_ROOT = Path(er.__file__).resolve().parent.parent @@ -47,7 +49,7 @@ class TestMarkerWrites: # the contract is content-unchanged, not never-exists. before = target.read_text(encoding="utf-8") if target.exists() else None try: - main_mod._write_marker_file(target, label="lazy-refresh-incomplete") + update_cmd._write_marker_file(target, label="lazy-refresh-incomplete") after = ( target.read_text(encoding="utf-8") if target.exists() else None ) @@ -64,7 +66,7 @@ class TestMarkerWrites: def test_still_writes_sandboxed(self, tmp_path): target = tmp_path / ".lazy-refresh-incomplete" - main_mod._write_marker_file(target, label="lazy-refresh-incomplete") + update_cmd._write_marker_file(target, label="lazy-refresh-incomplete") assert target.exists() assert "pid=" in target.read_text(encoding="utf-8") diff --git a/tests/hermes_cli/test_clear_stale_base_url.py b/tests/hermes_cli/test_clear_stale_base_url.py index 28855a9ae6..528988cc6d 100644 --- a/tests/hermes_cli/test_clear_stale_base_url.py +++ b/tests/hermes_cli/test_clear_stale_base_url.py @@ -23,7 +23,7 @@ class TestClearStaleOpenaiBaseUrl: def test_clears_when_provider_is_named(self, monkeypatch): """OPENAI_BASE_URL is cleared when config provider is a named provider.""" - from hermes_cli.main import _clear_stale_openai_base_url + from hermes_cli.main_provider_setup import _clear_stale_openai_base_url _write_provider("openrouter") save_env_value("OPENAI_BASE_URL", "http://localhost:11434/v1") @@ -35,7 +35,7 @@ class TestClearStaleOpenaiBaseUrl: def test_preserves_when_provider_is_custom(self, monkeypatch): """OPENAI_BASE_URL is NOT cleared when config provider is 'custom'.""" - from hermes_cli.main import _clear_stale_openai_base_url + from hermes_cli.main_provider_setup import _clear_stale_openai_base_url _write_provider("custom") save_env_value("OPENAI_BASE_URL", "http://localhost:11434/v1") diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index 834b13cee9..f7ba0b2ada 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -8,6 +8,9 @@ from unittest.mock import ANY, patch import pytest from hermes_cli.main import cmd_update, PROJECT_ROOT +from hermes_cli import main_web_build +from hermes_cli import main_install_repair +from hermes_cli import update_cmd def _make_run_side_effect(branch="main", verify_ok=True, commit_count="0"): @@ -107,11 +110,11 @@ class TestCmdUpdateNpmLockfileCache: monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path) (tmp_path / "package-lock.json").write_text('{"lockfileVersion": 3}') - hm._record_npm_lockfile_hash(tmp_path) + update_cmd._record_npm_lockfile_hash(tmp_path) assert ( self._cache_file(tmp_path, tmp_path).read_text() - == hm._npm_manifests_digest() + == update_cmd._npm_manifests_digest() ) def test_package_json_only_edit_defeats_skip(self, tmp_path, monkeypatch): @@ -124,7 +127,7 @@ class TestCmdUpdateNpmLockfileCache: (tmp_path / "package-lock.json").write_text('{"lockfileVersion": 3}') (tmp_path / "package.json").write_text('{"dependencies": {}}') (tmp_path / "node_modules").mkdir() - hm._record_npm_lockfile_hash(tmp_path) + update_cmd._record_npm_lockfile_hash(tmp_path) assert hm._npm_lockfile_changed(tmp_path) is False (tmp_path / "package.json").write_text( @@ -165,10 +168,10 @@ class TestCmdUpdateNpmLockfileCache: side_effect=lambda root: cache_roots.append(root) or False, ): monkeypatch.setenv("HERMES_HOME", str(shared_root)) - hm._update_node_dependencies() + update_cmd._update_node_dependencies() monkeypatch.setenv("HERMES_HOME", str(named_profile)) - hm._update_node_dependencies() + update_cmd._update_node_dependencies() assert cache_roots == [shared_root, shared_root] @@ -186,7 +189,7 @@ class TestCmdUpdateTermuxUvBootstrap: mock_run.return_value = subprocess.CompletedProcess([], 1, stdout="", stderr="") monkeypatch.setattr(hm, "_is_termux_env", lambda env=None: True) - uv_bin = hm._ensure_uv_for_termux(["/termux/python", "-m", "pip"]) + uv_bin = update_cmd._ensure_uv_for_termux(["/termux/python", "-m", "pip"]) assert uv_bin is None assert mock_run.call_count == 1 @@ -214,7 +217,7 @@ class TestCmdUpdateTermuxUvBootstrap: monkeypatch.setattr("hermes_cli.managed_uv.resolve_uv", lambda: None) monkeypatch.setattr("shutil.which", lambda name: pkg_uv if name == "uv" else None) - uv_bin = hm._ensure_uv_for_termux(["/termux/python", "-m", "pip"]) + uv_bin = update_cmd._ensure_uv_for_termux(["/termux/python", "-m", "pip"]) assert uv_bin == pkg_uv mock_run.assert_not_called() @@ -1011,7 +1014,7 @@ class TestCmdUpdateZipBranchRefusal: """ def test_zip_fallback_refuses_non_main_branch(self, capsys): - from hermes_cli.main import _update_via_zip + from hermes_cli.update_cmd import _update_via_zip args = SimpleNamespace(branch="bb/gui") with pytest.raises(SystemExit) as exc_info: @@ -1050,8 +1053,8 @@ termux = ["rich>=14"] ) monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path) - assert hm._load_installable_optional_extras(group="all") == ["mcp"] - assert hm._load_installable_optional_extras(group="termux-all") == ["termux", "mcp"] + assert main_install_repair._load_installable_optional_extras(group="all") == ["mcp"] + assert main_install_repair._load_installable_optional_extras(group="termux-all") == ["termux", "mcp"] class TestNodeRuntimeNpmResolution: @@ -1080,7 +1083,7 @@ class TestNodeRuntimeNpmResolution: with patch( "tools.browser_tool_install.warm_agent_browser_npx_cache", return_value=True ): - failed = hm._update_node_dependencies() + failed = update_cmd._update_node_dependencies() assert failed == ["ui-tui, web workspaces"] out = capsys.readouterr().out assert "mixed state" in out @@ -1106,11 +1109,11 @@ class TestNodeRuntimeNpmResolution: monkeypatch.setenv("PATH", "/mnt/c/Program Files/nodejs") with patch("subprocess.run") as mock_run, \ - patch.object(hm, "_web_ui_build_needed", return_value=True), \ + patch.object(main_web_build, "_web_ui_build_needed", return_value=True), \ patch.object(hm, "_desktop_packaged_executable", return_value=None), \ patch.object(hm, "_desktop_dist_exists", return_value=True), \ patch.object(hm, "_run_npm_install_deterministic") as mock_npm_install, \ - patch.object(hm, "_run_with_idle_timeout") as mock_idle_build, \ + patch.object(main_web_build, "_run_with_idle_timeout") as mock_idle_build, \ patch.object(hm, "_run_logged_subprocess") as mock_desktop_build: mock_run.side_effect = _make_run_side_effect( branch="main", verify_ok=True, commit_count="1" @@ -1332,7 +1335,7 @@ class TestUpdateNodeDependencies: popen_calls = [] mock_popen.side_effect = self._make_popen(popen_calls) - hm._update_node_dependencies() + update_cmd._update_node_dependencies() calls = self._popen_npm_calls(popen_calls) assert len(calls) == 1, f"expected exactly 1 npm call, got: {calls}" @@ -1369,7 +1372,7 @@ class TestUpdateNodeDependencies: popen_calls = [] mock_popen.side_effect = self._make_popen(popen_calls) - hm._update_node_dependencies() + update_cmd._update_node_dependencies() calls = self._popen_npm_calls(popen_calls) assert len(calls) == 1 @@ -1390,7 +1393,7 @@ class TestUpdateNodeDependencies: popen_calls = [] mock_popen.side_effect = self._make_popen(popen_calls) - hm._update_node_dependencies() + update_cmd._update_node_dependencies() calls = self._popen_npm_calls(popen_calls) assert len(calls) == 1 @@ -1409,7 +1412,7 @@ class TestUpdateNodeDependencies: monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path) monkeypatch.setattr(hm, "_npm_lockfile_changed", lambda root: False) - hm._update_node_dependencies() + update_cmd._update_node_dependencies() assert not self._npm_calls(mock_run), ( "npm must not run when _npm_lockfile_changed reports no change" @@ -1428,7 +1431,7 @@ class TestUpdateNodeDependencies: popen_calls = [] mock_popen.side_effect = self._make_popen(popen_calls) - hm._update_node_dependencies() + update_cmd._update_node_dependencies() calls = self._popen_npm_calls(popen_calls) assert len(calls) == 1, f"expected npm to run when lockfile changed; got: {calls}" @@ -1445,10 +1448,10 @@ class TestUpdateNodeDependencies: monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path) monkeypatch.setattr(hm, "_npm_lockfile_changed", lambda root: True) recorded = [] - monkeypatch.setattr(hm, "_record_npm_lockfile_hash", lambda root: recorded.append(root)) + monkeypatch.setattr(update_cmd, "_record_npm_lockfile_hash", lambda root: recorded.append(root)) mock_popen.side_effect = self._make_popen([], returncode=1, stderr_lines=["npm ERR!\n"]) - hm._update_node_dependencies() + update_cmd._update_node_dependencies() assert not recorded, "lockfile hash must not be recorded when npm install fails" @@ -1470,7 +1473,7 @@ class TestUpdateNodeDependencies: with patch( "tools.browser_tool_install.warm_agent_browser_npx_cache", return_value=True ) as mock_warm: - hm._update_node_dependencies() + update_cmd._update_node_dependencies() mock_warm.assert_called_once() @@ -1483,7 +1486,7 @@ class TestUpdateNodeDependencies: (tmp_path / "package.json").write_text("{}") monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path) - hm._update_node_dependencies() + update_cmd._update_node_dependencies() mock_run.assert_not_called() @@ -1495,7 +1498,7 @@ class TestUpdateNodeDependencies: monkeypatch.setattr(hm, "PROJECT_ROOT", tmp_path) - hm._update_node_dependencies() + update_cmd._update_node_dependencies() mock_run.assert_not_called() @@ -1512,7 +1515,7 @@ class TestUpdateNodeDependencies: popen_calls = [] mock_popen.side_effect = self._make_popen(popen_calls) - hm._update_node_dependencies() + update_cmd._update_node_dependencies() cwd_calls = [ c["kwargs"].get("cwd") diff --git a/tests/hermes_cli/test_cmd_update_apt.py b/tests/hermes_cli/test_cmd_update_apt.py index d9e8a3ea58..d519500711 100644 --- a/tests/hermes_cli/test_cmd_update_apt.py +++ b/tests/hermes_cli/test_cmd_update_apt.py @@ -7,7 +7,8 @@ from unittest.mock import patch import pytest -from hermes_cli.main import _cmd_update_check, cmd_update +from hermes_cli.main import cmd_update +from hermes_cli.update_cmd import _cmd_update_check def test_apt_stamp_is_detected_and_recommends_pkg_upgrade(tmp_path): diff --git a/tests/hermes_cli/test_cmd_update_docker.py b/tests/hermes_cli/test_cmd_update_docker.py index a4c52ab2c5..cde65bebbf 100644 --- a/tests/hermes_cli/test_cmd_update_docker.py +++ b/tests/hermes_cli/test_cmd_update_docker.py @@ -21,7 +21,8 @@ from unittest.mock import patch import pytest -from hermes_cli.main import _cmd_update_check, cmd_update +from hermes_cli.main import cmd_update +from hermes_cli.update_cmd import _cmd_update_check # ---------- cmd_update (apply path) ---------- diff --git a/tests/hermes_cli/test_codex_models.py b/tests/hermes_cli/test_codex_models.py index 50a5d51d64..7885293357 100644 --- a/tests/hermes_cli/test_codex_models.py +++ b/tests/hermes_cli/test_codex_models.py @@ -117,7 +117,7 @@ def test_fetch_from_api_keeps_supported_in_api_false_models(monkeypatch): def test_model_command_prompts_to_reuse_or_reauthenticate_codex_session(monkeypatch, capsys): - from hermes_cli.main import _model_flow_openai_codex + from hermes_cli.model_setup_flows import _model_flow_openai_codex captured = {"login_calls": 0} choices = iter(["2"]) diff --git a/tests/hermes_cli/test_custom_provider_model_switch.py b/tests/hermes_cli/test_custom_provider_model_switch.py index acbfe0b89a..43edf559d1 100644 --- a/tests/hermes_cli/test_custom_provider_model_switch.py +++ b/tests/hermes_cli/test_custom_provider_model_switch.py @@ -41,7 +41,7 @@ class TestCustomProviderModelSwitch: import yaml from agent.credential_pool import load_pool from hermes_cli.auth import read_credential_pool, write_credential_pool - from hermes_cli.main import _model_flow_custom + from hermes_cli.model_setup_flows import _model_flow_custom config_path = config_home / "config.yaml" config_path.write_text( @@ -87,7 +87,7 @@ class TestCustomProviderModelSwitch: }, ), \ patch("hermes_cli.secret_prompt.masked_secret_prompt", return_value="sk-new"), \ - patch("hermes_cli.main._prompt_custom_api_mode_selection", return_value=""), \ + patch("hermes_cli.main_provider_setup._prompt_custom_api_mode_selection", return_value=""), \ patch( "builtins.input", side_effect=[ @@ -122,7 +122,7 @@ class TestCustomProviderModelSwitch: def test_env_template_api_key_is_preserved_in_model_config(self, config_home, monkeypatch): """Selecting an env-backed custom provider must not inline the secret.""" import yaml - from hermes_cli.main import _model_flow_named_custom + from hermes_cli.model_setup_flows import _model_flow_named_custom config_path = config_home / "config.yaml" config_path.write_text( @@ -165,7 +165,7 @@ class TestCustomProviderModelSwitch: def test_key_env_custom_provider_persists_reference_not_secret(self, config_home, monkeypatch): """key_env custom providers should also avoid writing plaintext keys.""" import yaml - from hermes_cli.main import _model_flow_named_custom + from hermes_cli.model_setup_flows import _model_flow_named_custom config_path = config_home / "config.yaml" config_path.write_text( @@ -280,7 +280,7 @@ class TestCustomProviderModelSwitch: ``api_key`` belongs on disk. """ import yaml - from hermes_cli.main import _model_flow_named_custom + from hermes_cli.model_setup_flows import _model_flow_named_custom config_path = config_home / "config.yaml" config_path.write_text( @@ -394,7 +394,7 @@ class TestCustomProviderModelSwitch: template must keep it untouched. Only entries that never declared an ``api_key`` should skip the write.""" import yaml - from hermes_cli.main import _model_flow_named_custom + from hermes_cli.model_setup_flows import _model_flow_named_custom config_path = config_home / "config.yaml" config_path.write_text( @@ -448,7 +448,7 @@ class TestCustomProviderDiscoverModels: def test_discover_false_with_only_singular_model_skips_probe(self, config_home): """An active singular model is not an implicit discovery catalog.""" - from hermes_cli.main import _model_flow_named_custom + from hermes_cli.model_setup_flows import _model_flow_named_custom provider_info = { "name": "Headered Ollama", @@ -471,7 +471,7 @@ class TestCustomProviderDiscoverModels: def test_discover_false_saves_choice_from_configured_list(self, config_home): """User picks the 2nd configured model; it persists, list-driven.""" import yaml - from hermes_cli.main import _model_flow_named_custom + from hermes_cli.model_setup_flows import _model_flow_named_custom provider_info = { "name": "Baidu Coding", @@ -499,7 +499,7 @@ class TestCustomProviderDiscoverModels: """When discovery is on but the probe returns nothing, fall back to the configured models: list instead of forcing manual entry.""" import yaml - from hermes_cli.main import _model_flow_named_custom + from hermes_cli.model_setup_flows import _model_flow_named_custom provider_info = { "name": "My Gateway", @@ -522,7 +522,7 @@ class TestCustomProviderDiscoverModels: def test_discover_false_string_is_normalised(self, config_home): """String 'false' (hand-edited configs) disables discovery too.""" - from hermes_cli.main import _model_flow_named_custom + from hermes_cli.model_setup_flows import _model_flow_named_custom provider_info = { "name": "Baidu Coding", diff --git a/tests/hermes_cli/test_dashboard_lifecycle_flags.py b/tests/hermes_cli/test_dashboard_lifecycle_flags.py index cae7aa1403..c44dd7df9f 100644 --- a/tests/hermes_cli/test_dashboard_lifecycle_flags.py +++ b/tests/hermes_cli/test_dashboard_lifecycle_flags.py @@ -30,7 +30,7 @@ def _ns(**kw): class TestDashboardStatus: def test_status_no_processes(self, capsys): - with patch("hermes_cli.main._scan_dashboard_processes", return_value=[]), \ + with patch("hermes_cli.dashboard_procs._scan_dashboard_processes", return_value=[]), \ pytest.raises(SystemExit) as exc: cmd_dashboard(_ns(status=True)) assert exc.value.code == 0 @@ -46,9 +46,9 @@ class TestDashboardStatus: (12346, "python -m hermes_cli.main dashboard --host 0.0.0.0 --port 9120"), (12347, "hermes serve --host 100.94.65.93 --port 9119"), ] - with patch("hermes_cli.main._scan_dashboard_processes", return_value=processes), \ + with patch("hermes_cli.dashboard_procs._scan_dashboard_processes", return_value=processes), \ patch("gateway.status._pid_exists", return_value=True), \ - patch("hermes_cli.main._dashboard_listening", return_value=True), \ + patch("hermes_cli.main_dashboard._dashboard_listening", return_value=True), \ pytest.raises(SystemExit) as exc: cmd_dashboard(_ns(status=True)) # Status is informational — always exits 0. @@ -70,7 +70,7 @@ class TestDashboardStatus: raise ImportError("fastapi missing") return orig_import(name, *a, **kw) - with patch("hermes_cli.main._scan_dashboard_processes", return_value=[]), \ + with patch("hermes_cli.dashboard_procs._scan_dashboard_processes", return_value=[]), \ patch("builtins.__import__", side_effect=fake_import), \ pytest.raises(SystemExit) as exc: cmd_dashboard(_ns(status=True)) @@ -85,7 +85,7 @@ class TestDashboardStop: scans = iter([[12345, 12346], []]) with patch("hermes_cli.main._find_stale_dashboard_pids", side_effect=lambda: next(scans)), \ - patch("hermes_cli.main._kill_stale_dashboard_processes") as mock_kill, \ + patch("hermes_cli.dashboard_procs._kill_stale_dashboard_processes") as mock_kill, \ pytest.raises(SystemExit) as exc: cmd_dashboard(_ns(stop=True)) mock_kill.assert_called_once() @@ -103,7 +103,7 @@ class TestDashboardStop: scans = iter([[12345], [12345]]) # both scans find the same PID with patch("hermes_cli.main._find_stale_dashboard_pids", side_effect=lambda: next(scans)), \ - patch("hermes_cli.main._kill_stale_dashboard_processes"), \ + patch("hermes_cli.dashboard_procs._kill_stale_dashboard_processes"), \ pytest.raises(SystemExit) as exc: cmd_dashboard(_ns(stop=True)) assert exc.value.code == 1 @@ -167,7 +167,7 @@ class TestArgparseWiring: # be too invasive. Instead parse args as if via the CLI by # intercepting parse_args. This is overkill for a smoke test — # we just want to know the flags don't KeyError. - with patch("hermes_cli.main._scan_dashboard_processes", return_value=[]), \ + with patch("hermes_cli.dashboard_procs._scan_dashboard_processes", return_value=[]), \ pytest.raises(SystemExit) as exc: mod.cmd_dashboard(_ns(status=True)) assert exc.value.code == 0 diff --git a/tests/hermes_cli/test_dashboard_unified_launch.py b/tests/hermes_cli/test_dashboard_unified_launch.py index 3bb122136f..f92068da97 100644 --- a/tests/hermes_cli/test_dashboard_unified_launch.py +++ b/tests/hermes_cli/test_dashboard_unified_launch.py @@ -8,6 +8,7 @@ launching profile preselected. `--isolated` opts out. import sys import types import pytest +from hermes_cli import main_dashboard @pytest.fixture @@ -34,7 +35,7 @@ class TestUnifiedDashboardRouting: monkeypatch.setattr( "hermes_cli.profiles.get_active_profile_name", lambda: "worker_x" ) - monkeypatch.setattr(main_mod, "_dashboard_listening", lambda host, port: False) + monkeypatch.setattr(main_dashboard, "_dashboard_listening", lambda host, port: False) execs = [] def fake_exec(exe, argv, env): @@ -71,8 +72,7 @@ class TestUnifiedDashboardRouting: "hermes_cli.profiles.get_active_profile_name", lambda: "worker_x" ) listening_calls = [] - monkeypatch.setattr( - main_mod, "_dashboard_listening", + monkeypatch.setattr(main_dashboard, "_dashboard_listening", lambda host, port: listening_calls.append(1) or False, ) execs = [] diff --git a/tests/hermes_cli/test_desktop_exe_integrity.py b/tests/hermes_cli/test_desktop_exe_integrity.py index bdff61e62a..0f151c33da 100644 --- a/tests/hermes_cli/test_desktop_exe_integrity.py +++ b/tests/hermes_cli/test_desktop_exe_integrity.py @@ -24,6 +24,7 @@ from unittest.mock import patch import pytest from hermes_cli import main as cli_main +from hermes_cli import main_desktop PE_AMD64 = 0x8664 PE_ARM64 = 0xAA64 @@ -154,7 +155,7 @@ def test_native_machine_reports_os_arch_not_process_arch(): # WinDLL only exists on Windows; create=True so Linux/macOS CI can stub it. with patch.object(ctypes, "WinDLL", _fake_windll(PE_ARM64), create=True), \ patch("platform.machine", return_value="AMD64"): - assert cli_main._windows_native_machine() == "ARM64" + assert main_desktop._windows_native_machine() == "ARM64" @pytest.mark.windows_only @@ -178,7 +179,7 @@ def test_expected_machines_prefers_user_runnable_api_over_arch_name(monkeypatch) ), create=True, ), patch("platform.machine", return_value="AMD64"): - assert cli_main._expected_windows_pe_machines() == {PE_ARM64, PE_AMD64} + assert main_desktop._expected_windows_pe_machines() == {PE_ARM64, PE_AMD64} @@ -214,12 +215,12 @@ def test_rollback_restores_backup_and_keeps_corrupt_copy(tmp_path): backup_exe = desktop_dir / "release" / "win-unpacked.bak" / "Hermes.exe" make_pe(backup_exe, PE_AMD64) # valid old build - with patch("hermes_cli.main._windows_native_machine", return_value="AMD64"): - restored = cli_main._rollback_desktop_from_backup(exe) + with patch("hermes_cli.main_desktop._windows_native_machine", return_value="AMD64"): + restored = main_desktop._rollback_desktop_from_backup(exe) assert restored == exe # The restored exe is the old, valid build. - assert cli_main._parse_pe_machine(exe) == PE_AMD64 + assert main_desktop._parse_pe_machine(exe) == PE_AMD64 assert exe.stat().st_size == 0x400 # Corrupt tree preserved for diagnostics; backup consumed. assert (desktop_dir / "release" / "win-unpacked.corrupt" / "Hermes.exe").exists() @@ -245,9 +246,9 @@ def test_gate_fails_clearly_without_backup(tmp_path, capsys): fake.parent.mkdir(parents=True) fake.write_bytes(b"proxy error" + b" " * 600) - with patch("hermes_cli.main._purge_electron_build_cache", return_value=[]), \ - patch("hermes_cli.main._desktop_stamp_path", return_value=tmp_path / "stamp.json"): - verified, rolled_back = cli_main._ensure_desktop_exe_launchable(desktop_dir, exe) + with patch("hermes_cli.main_desktop._purge_electron_build_cache", return_value=[]), \ + patch("hermes_cli.main_desktop._desktop_stamp_path", return_value=tmp_path / "stamp.json"): + verified, rolled_back = main_desktop._ensure_desktop_exe_launchable(desktop_dir, exe) assert verified is None assert rolled_back is False @@ -313,11 +314,11 @@ def test_build_only_fails_when_pack_produces_corrupt_exe(tmp_path, monkeypatch, patch("hermes_cli.main._resolve_node_runtime_npm", return_value="npm.cmd"), \ patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok), \ patch("hermes_cli.main._desktop_build_needed", return_value=True), \ - patch("hermes_cli.main._stop_desktop_processes_locking_build", return_value=[]), \ - patch("hermes_cli.main._purge_electron_build_cache", return_value=[]), \ - patch("hermes_cli.main._desktop_stamp_path", return_value=tmp_path / "stamp.json"), \ - patch("hermes_cli.main._write_desktop_build_stamp") as mock_stamp, \ - patch("hermes_cli.main._windows_native_machine", return_value="AMD64"), \ + patch("hermes_cli.main_desktop._stop_desktop_processes_locking_build", return_value=[]), \ + patch("hermes_cli.main_desktop._purge_electron_build_cache", return_value=[]), \ + patch("hermes_cli.main_desktop._desktop_stamp_path", return_value=tmp_path / "stamp.json"), \ + patch("hermes_cli.main_desktop._write_desktop_build_stamp") as mock_stamp, \ + patch("hermes_cli.main_desktop._windows_native_machine", return_value="AMD64"), \ patch("hermes_cli.main.subprocess.run", side_effect=pack_into_staging), \ pytest.raises(SystemExit) as exc: cli_main.cmd_gui(_ns()) @@ -325,7 +326,7 @@ def test_build_only_fails_when_pack_produces_corrupt_exe(tmp_path, monkeypatch, assert exc.value.code == 1 # The previous working exe was never touched... assert live_exe.read_bytes() == live_bytes - assert cli_main._parse_pe_machine(live_exe) == PE_AMD64 + assert main_desktop._parse_pe_machine(live_exe) == PE_AMD64 # ...the staged corrupt tree was discarded... assert not list((desktop_dir / "release").glob(".staging-*")) # ...and the poisoned build was never stamped as good. diff --git a/tests/hermes_cli/test_ensure_acp_launcher.py b/tests/hermes_cli/test_ensure_acp_launcher.py index fac777858d..17026839e2 100644 --- a/tests/hermes_cli/test_ensure_acp_launcher.py +++ b/tests/hermes_cli/test_ensure_acp_launcher.py @@ -13,7 +13,7 @@ from unittest.mock import patch import pytest -from hermes_cli.main import _ensure_acp_launcher +from hermes_cli.update_cmd import _ensure_acp_launcher @pytest.fixture diff --git a/tests/hermes_cli/test_gemini_free_tier_setup_block.py b/tests/hermes_cli/test_gemini_free_tier_setup_block.py index 56f1261a8a..dd73225abb 100644 --- a/tests/hermes_cli/test_gemini_free_tier_setup_block.py +++ b/tests/hermes_cli/test_gemini_free_tier_setup_block.py @@ -34,7 +34,7 @@ class TestGeminiSetupFreeTierBlock: """Free-tier probe result -> provider is NOT saved, message is printed.""" monkeypatch.setenv("GOOGLE_API_KEY", "fake-free-tier-key") - from hermes_cli.main import _model_flow_api_key_provider + from hermes_cli.model_setup_flows import _model_flow_api_key_provider from hermes_cli.config import load_config # Mock the probe to claim this is a free-tier key @@ -68,7 +68,7 @@ class TestGeminiSetupFreeTierBlock: """Paid-tier probe result -> provider IS saved normally.""" monkeypatch.setenv("GOOGLE_API_KEY", "fake-paid-tier-key") - from hermes_cli.main import _model_flow_api_key_provider + from hermes_cli.model_setup_flows import _model_flow_api_key_provider from hermes_cli.config import load_config with patch( @@ -98,7 +98,7 @@ class TestGeminiSetupFreeTierBlock: """Probe must only run for provider_id == 'gemini', not for other providers.""" monkeypatch.setenv("DEEPSEEK_API_KEY", "fake-key") - from hermes_cli.main import _model_flow_api_key_provider + from hermes_cli.model_setup_flows import _model_flow_api_key_provider from hermes_cli.config import load_config with patch( diff --git a/tests/hermes_cli/test_gmi_provider.py b/tests/hermes_cli/test_gmi_provider.py index b7afd65dac..732dab2986 100644 --- a/tests/hermes_cli/test_gmi_provider.py +++ b/tests/hermes_cli/test_gmi_provider.py @@ -316,7 +316,7 @@ class TestGmiMainFlow: "builtins.input", return_value="", ): - from hermes_cli.main import _model_flow_api_key_provider + from hermes_cli.model_setup_flows import _model_flow_api_key_provider _model_flow_api_key_provider(load_config(), "gmi", "old-model") diff --git a/tests/hermes_cli/test_gui_command.py b/tests/hermes_cli/test_gui_command.py index c9f6e483e2..5419ad8d16 100644 --- a/tests/hermes_cli/test_gui_command.py +++ b/tests/hermes_cli/test_gui_command.py @@ -11,6 +11,9 @@ from unittest.mock import patch import pytest from hermes_cli import main as cli_main +from hermes_cli import main_desktop +from hermes_cli import main_install_repair +from hermes_cli import main_web_build @pytest.fixture(autouse=True) @@ -145,12 +148,12 @@ def test_gui_installs_packages_and_launches_desktop_app(tmp_path, monkeypatch): launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 0) with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ - patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok) as mock_install, \ - patch("hermes_cli.main._desktop_build_needed", return_value=True), \ - patch("hermes_cli.main._write_desktop_build_stamp"), \ - patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \ - patch("hermes_cli.main._desktop_linux_sandbox_fixup", return_value=True), \ - patch("hermes_cli.main._register_linux_desktop_entry"), \ + patch("hermes_cli.main_web_build._run_npm_install_deterministic", return_value=install_ok) as mock_install, \ + patch("hermes_cli.main_desktop._desktop_build_needed", return_value=True), \ + patch("hermes_cli.main_desktop._write_desktop_build_stamp"), \ + patch("hermes_cli.main_desktop._desktop_macos_relaunchable_fixup"), \ + patch("hermes_cli.main_desktop._desktop_linux_sandbox_fixup", return_value=True), \ + patch("hermes_cli.main_desktop._register_linux_desktop_entry"), \ patch("hermes_cli.main.subprocess.run", side_effect=_pack_into_staging(root)) as mock_run, \ pytest.raises(SystemExit) as exc: cli_main.cmd_gui(_ns()) @@ -209,12 +212,12 @@ def test_gui_install_env_prepends_managed_node_on_bare_path(tmp_path, monkeypatc # cmd_gui legitimately shells out one extra time — e.g. the Linux sandbox # fixup, which fires on hosts where chrome-sandbox isn't already # root-owned+4755. Assert on the install env, not on a call count. - with patch("hermes_cli.main._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \ - patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok) as mock_install, \ - patch("hermes_cli.main._desktop_build_needed", return_value=True), \ - patch("hermes_cli.main._write_desktop_build_stamp"), \ - patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \ - patch("hermes_cli.main._desktop_linux_sandbox_fixup", return_value=True), \ + with patch("hermes_cli.main_install_repair._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main_web_build._run_npm_install_deterministic", return_value=install_ok) as mock_install, \ + patch("hermes_cli.main_desktop._desktop_build_needed", return_value=True), \ + patch("hermes_cli.main_desktop._write_desktop_build_stamp"), \ + patch("hermes_cli.main_desktop._desktop_macos_relaunchable_fixup"), \ + patch("hermes_cli.main_desktop._desktop_linux_sandbox_fixup", return_value=True), \ patch("hermes_cli.main.subprocess.run", return_value=launch_ok), \ pytest.raises(SystemExit): cli_main.cmd_gui(_ns(skip_build=False)) @@ -274,9 +277,9 @@ def test_purge_electron_build_cache_clears_all_zips_and_unpacked_dir(tmp_path, m (unpacked / "LICENSE.electron.txt").write_text("x", encoding="utf-8") (unpacked / "resources.pak").write_text("x", encoding="utf-8") - monkeypatch.setattr(cli_main, "_electron_download_cache_dirs", lambda: [cache]) + monkeypatch.setattr(main_desktop, "_electron_download_cache_dirs", lambda: [cache]) - removed = cli_main._purge_electron_build_cache(desktop_dir) + removed = main_desktop._purge_electron_build_cache(desktop_dir) assert clean in removed assert prepended in removed @@ -310,10 +313,10 @@ def test_gui_does_not_retry_after_packaged_executable_exists(tmp_path, monkeypat pack_fail = _pack_into_staging(root, content="half-signed", returncode=1) with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ - patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok), \ - patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \ - patch("hermes_cli.main._purge_electron_build_cache", return_value=[Path("/c/electron.zip")]) as mock_purge, \ - patch("hermes_cli.main._redownload_electron_dist", return_value=True) as mock_dl, \ + patch("hermes_cli.main_web_build._run_npm_install_deterministic", return_value=install_ok), \ + patch("hermes_cli.main_desktop._desktop_macos_relaunchable_fixup"), \ + patch("hermes_cli.main_desktop._purge_electron_build_cache", return_value=[Path("/c/electron.zip")]) as mock_purge, \ + patch("hermes_cli.main_desktop._redownload_electron_dist", return_value=True) as mock_dl, \ patch("hermes_cli.main.subprocess.run", side_effect=pack_fail) as mock_run, \ pytest.raises(SystemExit) as exc: cli_main.cmd_gui(_ns()) @@ -349,14 +352,14 @@ def test_electron_dist_ok_on_this_host(): root = Path(td) electron = root / "node_modules" / "electron" (electron / "dist").mkdir(parents=True) - assert cli_main._electron_dist_ok(root) is False + assert main_desktop._electron_dist_ok(root) is False - binp = cli_main._electron_dist_binary(root) + binp = main_desktop._electron_dist_binary(root) # The resolved binary must live under the dist dir we just created. assert (electron / "dist") in binp.parents binp.parent.mkdir(parents=True, exist_ok=True) binp.write_text("", encoding="utf-8") - assert cli_main._electron_dist_ok(root) is True + assert main_desktop._electron_dist_ok(root) is True @pytest.mark.linux_only @@ -370,7 +373,7 @@ def test_electron_dist_binary_basename_linux(): actually execute it. """ root = Path("/tmp/does-not-need-to-exist") - assert cli_main._electron_dist_binary(root) == ( + assert main_desktop._electron_dist_binary(root) == ( root / "node_modules" / "electron" / "dist" / "electron" ) @@ -379,7 +382,7 @@ def test_electron_dist_binary_basename_linux(): def test_electron_dist_binary_basename_windows(): """``dist/electron.exe`` on Windows — the ``.exe`` suffix is the whole point.""" root = Path("C:/does-not-need-to-exist") - assert cli_main._electron_dist_binary(root) == ( + assert main_desktop._electron_dist_binary(root) == ( root / "node_modules" / "electron" / "dist" / "electron.exe" ) @@ -392,7 +395,7 @@ def test_electron_dist_binary_basename_macos(): binary doesn't" check can't just stat the dist directory. """ root = Path("/tmp/does-not-need-to-exist") - assert cli_main._electron_dist_binary(root) == ( + assert main_desktop._electron_dist_binary(root) == ( root / "node_modules" / "electron" @@ -496,7 +499,7 @@ def test_desktop_macos_local_codesign_signs_native_binaries(tmp_path, monkeypatc app = _make_signable_app(desktop_dir) calls = _collect_codesign_calls(monkeypatch) - assert cli_main._desktop_macos_local_codesign(app, desktop_dir=desktop_dir) is True + assert main_desktop._desktop_macos_local_codesign(app, desktop_dir=desktop_dir) is True signed = [c[-1] for c in calls if c[:3] == ["/usr/bin/codesign", "--force", "--sign"]] assert str(app / "Contents" / "Resources" / "app.asar.unpacked" / "node_modules" / "pty" / "pty.node") in signed @@ -534,13 +537,13 @@ def test_relaunchable_fixup_falls_back_to_legacy_adhoc_on_failure(tmp_path, monk cli_main.shutil, "which", lambda name: "/usr/bin/codesign" if name == "codesign" else None ) monkeypatch.setattr(cli_main.subprocess, "run", fake_run) - monkeypatch.setattr(cli_main, "_desktop_macos_has_valid_real_signature", lambda a: False) - monkeypatch.setattr(cli_main, "_desktop_macos_local_signing_identity", lambda: None) + monkeypatch.setattr(main_desktop, "_desktop_macos_has_valid_real_signature", lambda a: False) + monkeypatch.setattr(main_desktop, "_desktop_macos_local_signing_identity", lambda: None) def boom(*a, **kw): raise subprocess.CalledProcessError(1, ["codesign"]) - monkeypatch.setattr(cli_main, "_desktop_macos_local_codesign", boom) + monkeypatch.setattr(main_desktop, "_desktop_macos_local_codesign", boom) assert cli_main._desktop_macos_relaunchable_fixup(desktop_dir) is True assert ["xattr", "-cr", str(app)] in calls @@ -583,12 +586,12 @@ def test_setup_tcc_identity_creates_cert_imports_trusts_and_configures(tmp_path, return _fake_proc(cmd) monkeypatch.setattr(cli_main.subprocess, "run", fake_run) - monkeypatch.setattr(cli_main, "_desktop_packaged_executable", lambda d: None) - monkeypatch.setattr(cli_main, "_desktop_macos_relaunchable_fixup", lambda d: True) + monkeypatch.setattr(main_desktop, "_desktop_packaged_executable", lambda d: None) + monkeypatch.setattr(main_desktop, "_desktop_macos_relaunchable_fixup", lambda d: True) # Avoid writing the real user config. monkeypatch.setattr("hermes_cli.config.set_config_value", lambda key, value: None) - assert cli_main._desktop_macos_setup_tcc_identity(identity) is True + assert main_desktop._desktop_macos_setup_tcc_identity(identity) is True out = capsys.readouterr().out assert "created, imported, and trusted self-signed identity" in out @@ -642,11 +645,11 @@ def test_setup_tcc_identity_retries_pkcs12_with_legacy_on_mac_verification_failu return _fake_proc(cmd) monkeypatch.setattr(cli_main.subprocess, "run", fake_run) - monkeypatch.setattr(cli_main, "_desktop_packaged_executable", lambda d: None) - monkeypatch.setattr(cli_main, "_desktop_macos_relaunchable_fixup", lambda d: True) + monkeypatch.setattr(main_desktop, "_desktop_packaged_executable", lambda d: None) + monkeypatch.setattr(main_desktop, "_desktop_macos_relaunchable_fixup", lambda d: True) monkeypatch.setattr("hermes_cli.config.set_config_value", lambda key, value: None) - assert cli_main._desktop_macos_setup_tcc_identity(identity) is True + assert main_desktop._desktop_macos_setup_tcc_identity(identity) is True # Two pkcs12 exports (plain then -legacy) and two import attempts. pkcs12_calls = [c for c in calls if c[0] == "/usr/bin/openssl" and "pkcs12" in c] @@ -675,7 +678,7 @@ def test_setup_tcc_identity_fails_when_trust_step_fails(tmp_path, monkeypatch, c monkeypatch.setattr(cli_main.subprocess, "run", fake_run) - assert cli_main._desktop_macos_setup_tcc_identity("Hermes Local Signing") is False + assert main_desktop._desktop_macos_setup_tcc_identity("Hermes Local Signing") is False assert "could not trust the certificate" in capsys.readouterr().out @@ -698,7 +701,7 @@ def test_setup_tcc_identity_fails_when_identity_never_becomes_valid(tmp_path, mo monkeypatch.setattr(cli_main.subprocess, "run", fake_run) - assert cli_main._desktop_macos_setup_tcc_identity("Hermes Local Signing") is False + assert main_desktop._desktop_macos_setup_tcc_identity("Hermes Local Signing") is False assert "not a VALID code-signing identity" in capsys.readouterr().out @@ -721,11 +724,11 @@ def test_setup_tcc_identity_skips_generation_when_already_valid(tmp_path, monkey return _fake_proc(cmd) monkeypatch.setattr(cli_main.subprocess, "run", fake_run) - monkeypatch.setattr(cli_main, "_desktop_packaged_executable", lambda d: None) - monkeypatch.setattr(cli_main, "_desktop_macos_relaunchable_fixup", lambda d: True) + monkeypatch.setattr(main_desktop, "_desktop_packaged_executable", lambda d: None) + monkeypatch.setattr(main_desktop, "_desktop_macos_relaunchable_fixup", lambda d: True) monkeypatch.setattr("hermes_cli.config.set_config_value", lambda key, value: None) - assert cli_main._desktop_macos_setup_tcc_identity("Hermes Local Signing") is True + assert main_desktop._desktop_macos_setup_tcc_identity("Hermes Local Signing") is True out = capsys.readouterr().out assert "already valid in keychain" in out @@ -763,11 +766,11 @@ def test_setup_tcc_identity_untrusted_existing_cert_is_repaired(tmp_path, monkey return _fake_proc(cmd) monkeypatch.setattr(cli_main.subprocess, "run", fake_run) - monkeypatch.setattr(cli_main, "_desktop_packaged_executable", lambda d: None) - monkeypatch.setattr(cli_main, "_desktop_macos_relaunchable_fixup", lambda d: True) + monkeypatch.setattr(main_desktop, "_desktop_packaged_executable", lambda d: None) + monkeypatch.setattr(main_desktop, "_desktop_macos_relaunchable_fixup", lambda d: True) monkeypatch.setattr("hermes_cli.config.set_config_value", lambda key, value: None) - assert cli_main._desktop_macos_setup_tcc_identity("Hermes Local Signing") is True + assert main_desktop._desktop_macos_setup_tcc_identity("Hermes Local Signing") is True assert any(c[0] == "/usr/bin/security" and c[1] == "add-trusted-cert" for c in calls) @@ -775,7 +778,7 @@ def test_setup_tcc_identity_non_macos_skips(tmp_path, monkeypatch, capsys): """On non-macOS the setup is a no-op failure (not a crash).""" monkeypatch.setattr(cli_main.sys, "platform", "linux") - assert cli_main._desktop_macos_setup_tcc_identity() is False + assert main_desktop._desktop_macos_setup_tcc_identity() is False assert "macOS-only" in capsys.readouterr().out @@ -786,8 +789,8 @@ def test_cmd_gui_setup_tcc_identity_exits_before_build(tmp_path, monkeypatch): monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) _make_packaged_executable(root, monkeypatch) - with patch("hermes_cli.main._desktop_macos_setup_tcc_identity", return_value=True) as mock_setup, \ - patch("hermes_cli.main._run_npm_install_deterministic") as mock_install, \ + with patch("hermes_cli.main_desktop._desktop_macos_setup_tcc_identity", return_value=True) as mock_setup, \ + patch("hermes_cli.main_web_build._run_npm_install_deterministic") as mock_install, \ pytest.raises(SystemExit) as exc: cli_main.cmd_gui(_ns(setup_tcc_identity=True, identity="Hermes Local Signing")) @@ -821,11 +824,10 @@ def test_relaunchable_fixup_stable_identity_never_touches_keychain(tmp_path, mon app = exe.parents[2] calls: list[list[str]] = [] - monkeypatch.setattr(cli_main, "_desktop_macos_has_valid_real_signature", lambda a: False) - monkeypatch.setattr( - cli_main, "_desktop_macos_local_signing_identity", lambda: "Developer ID Application: Example" + monkeypatch.setattr(main_desktop, "_desktop_macos_has_valid_real_signature", lambda a: False) + monkeypatch.setattr(main_desktop, "_desktop_macos_local_signing_identity", lambda: "Developer ID Application: Example" ) - monkeypatch.setattr(cli_main, "_desktop_macos_local_codesign", lambda app, **kw: True) + monkeypatch.setattr(main_desktop, "_desktop_macos_local_codesign", lambda app, **kw: True) monkeypatch.setattr( cli_main.subprocess, "run", lambda cmd, **kw: calls.append(list(cmd)) or subprocess.CompletedProcess(cmd, 0), @@ -855,9 +857,9 @@ def test_relaunchable_fixup_default_noconfig_success_never_touches_keychain(tmp_ app = exe.parents[2] calls: list[list[str]] = [] - monkeypatch.setattr(cli_main, "_desktop_macos_has_valid_real_signature", lambda a: False) - monkeypatch.setattr(cli_main, "_desktop_macos_local_signing_identity", lambda: None) - monkeypatch.setattr(cli_main, "_desktop_macos_local_codesign", lambda app, **kw: True) + monkeypatch.setattr(main_desktop, "_desktop_macos_has_valid_real_signature", lambda a: False) + monkeypatch.setattr(main_desktop, "_desktop_macos_local_signing_identity", lambda: None) + monkeypatch.setattr(main_desktop, "_desktop_macos_local_codesign", lambda app, **kw: True) monkeypatch.setattr( cli_main.subprocess, "run", lambda cmd, **kw: calls.append(list(cmd)) or subprocess.CompletedProcess(cmd, 0), @@ -903,13 +905,13 @@ def test_relaunchable_fixup_legacy_adhoc_failure_never_touches_keychain(tmp_path cli_main.shutil, "which", lambda name: "/usr/bin/codesign" if name == "codesign" else None ) monkeypatch.setattr(cli_main.subprocess, "run", fake_run) - monkeypatch.setattr(cli_main, "_desktop_macos_has_valid_real_signature", lambda a: False) - monkeypatch.setattr(cli_main, "_desktop_macos_local_signing_identity", lambda: None) + monkeypatch.setattr(main_desktop, "_desktop_macos_has_valid_real_signature", lambda a: False) + monkeypatch.setattr(main_desktop, "_desktop_macos_local_signing_identity", lambda: None) def boom(*a, **kw): raise subprocess.CalledProcessError(1, ["codesign"]) - monkeypatch.setattr(cli_main, "_desktop_macos_local_codesign", boom) + monkeypatch.setattr(main_desktop, "_desktop_macos_local_codesign", boom) assert cli_main._desktop_macos_relaunchable_fixup(desktop_dir) is False assert ["/usr/bin/codesign", "--force", "--deep", "--sign", "-", str(app)] in calls @@ -948,13 +950,13 @@ def test_relaunchable_fixup_legacy_adhoc_success_still_verifies_and_never_delete cli_main.shutil, "which", lambda name: "/usr/bin/codesign" if name == "codesign" else None ) monkeypatch.setattr(cli_main.subprocess, "run", fake_run) - monkeypatch.setattr(cli_main, "_desktop_macos_has_valid_real_signature", lambda a: False) - monkeypatch.setattr(cli_main, "_desktop_macos_local_signing_identity", lambda: None) + monkeypatch.setattr(main_desktop, "_desktop_macos_has_valid_real_signature", lambda a: False) + monkeypatch.setattr(main_desktop, "_desktop_macos_local_signing_identity", lambda: None) def boom(*a, **kw): raise subprocess.CalledProcessError(1, ["codesign"]) - monkeypatch.setattr(cli_main, "_desktop_macos_local_codesign", boom) + monkeypatch.setattr(main_desktop, "_desktop_macos_local_codesign", boom) assert cli_main._desktop_macos_relaunchable_fixup(desktop_dir) is True assert ["/usr/bin/codesign", "--force", "--deep", "--sign", "-", str(app)] in calls @@ -986,9 +988,9 @@ def test_gui_registers_linux_desktop_entry_before_launch(tmp_path, monkeypatch): launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 0) - with patch("hermes_cli.main._desktop_build_needed", return_value=False), \ - patch("hermes_cli.main._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \ - patch("hermes_cli.main._desktop_linux_sandbox_fixup", return_value=True), \ + with patch("hermes_cli.main_desktop._desktop_build_needed", return_value=False), \ + patch("hermes_cli.main_install_repair._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main_desktop._desktop_linux_sandbox_fixup", return_value=True), \ patch("hermes_cli.main.subprocess.run", return_value=launch_ok), \ pytest.raises(SystemExit): cli_main.cmd_gui(_ns()) @@ -1011,9 +1013,9 @@ def test_gui_launches_even_when_desktop_entry_install_fails(tmp_path, monkeypatc launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 0) - with patch("hermes_cli.main._desktop_build_needed", return_value=False), \ - patch("hermes_cli.main._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \ - patch("hermes_cli.main._desktop_linux_sandbox_fixup", return_value=True), \ + with patch("hermes_cli.main_desktop._desktop_build_needed", return_value=False), \ + patch("hermes_cli.main_install_repair._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main_desktop._desktop_linux_sandbox_fixup", return_value=True), \ patch("hermes_cli.main.subprocess.run", return_value=launch_ok) as mock_run, \ pytest.raises(SystemExit) as exc: cli_main.cmd_gui(_ns()) @@ -1041,9 +1043,9 @@ def test_gui_skips_desktop_entry_off_linux(tmp_path, monkeypatch): launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 0) - with patch("hermes_cli.main._desktop_build_needed", return_value=False), \ - patch("hermes_cli.main._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \ - patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \ + with patch("hermes_cli.main_desktop._desktop_build_needed", return_value=False), \ + patch("hermes_cli.main_install_repair._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main_desktop._desktop_macos_relaunchable_fixup"), \ patch("hermes_cli.main.subprocess.run", return_value=launch_ok), \ pytest.raises(SystemExit) as exc: cli_main.cmd_gui(_ns()) @@ -1064,7 +1066,7 @@ def test_gui_skips_desktop_entry_off_linux(tmp_path, monkeypatch): def test_desktop_launch_options_normalizes_password_store(raw, expected): cfg = {"desktop": {"password_store": raw}} with patch("hermes_cli.config.load_config", return_value=cfg): - _, _, store, _ = cli_main._desktop_launch_options() + _, _, store, _ = main_desktop._desktop_launch_options() assert store == expected @@ -1082,13 +1084,13 @@ def test_desktop_launch_options_normalizes_ozone_hint(raw, expected): """``desktop.ozone_platform_hint`` normalizes to x11/wayland/auto.""" cfg = {"desktop": {"ozone_platform_hint": raw}} with patch("hermes_cli.config.load_config", return_value=cfg): - _, _, _, hint = cli_main._desktop_launch_options() + _, _, _, hint = main_desktop._desktop_launch_options() assert hint == expected def test_desktop_launch_options_ozone_hint_defaults_auto(): with patch("hermes_cli.config.load_config", return_value={}): - assert cli_main._desktop_launch_options()[3] == "auto" + assert main_desktop._desktop_launch_options()[3] == "auto" def test_gui_bridges_ozone_hint_to_launch_env(tmp_path, monkeypatch): @@ -1102,11 +1104,11 @@ def test_gui_bridges_ozone_hint_to_launch_env(tmp_path, monkeypatch): cfg = {"desktop": {"ozone_platform_hint": "x11"}} with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ - patch("hermes_cli.main._run_npm_install_deterministic", return_value=ok), \ - patch("hermes_cli.main._desktop_build_needed", return_value=True), \ - patch("hermes_cli.main._write_desktop_build_stamp"), \ - patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \ - patch("hermes_cli.main._desktop_linux_sandbox_fixup", return_value=True), \ + patch("hermes_cli.main_web_build._run_npm_install_deterministic", return_value=ok), \ + patch("hermes_cli.main_desktop._desktop_build_needed", return_value=True), \ + patch("hermes_cli.main_desktop._write_desktop_build_stamp"), \ + patch("hermes_cli.main_desktop._desktop_macos_relaunchable_fixup"), \ + patch("hermes_cli.main_desktop._desktop_linux_sandbox_fixup", return_value=True), \ patch("hermes_cli.config.load_config", return_value=cfg), \ patch("hermes_cli.linux_desktop_entry.install_desktop_entry", return_value=None), \ patch("hermes_cli.main.subprocess.run", side_effect=_pack_into_staging(root)) as mock_run, \ @@ -1118,11 +1120,11 @@ def test_gui_bridges_ozone_hint_to_launch_env(tmp_path, monkeypatch): monkeypatch.setenv("ELECTRON_OZONE_PLATFORM_HINT", "wayland") with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ - patch("hermes_cli.main._run_npm_install_deterministic", return_value=ok), \ - patch("hermes_cli.main._desktop_build_needed", return_value=True), \ - patch("hermes_cli.main._write_desktop_build_stamp"), \ - patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \ - patch("hermes_cli.main._desktop_linux_sandbox_fixup", return_value=True), \ + patch("hermes_cli.main_web_build._run_npm_install_deterministic", return_value=ok), \ + patch("hermes_cli.main_desktop._desktop_build_needed", return_value=True), \ + patch("hermes_cli.main_desktop._write_desktop_build_stamp"), \ + patch("hermes_cli.main_desktop._desktop_macos_relaunchable_fixup"), \ + patch("hermes_cli.main_desktop._desktop_linux_sandbox_fixup", return_value=True), \ patch("hermes_cli.config.load_config", return_value=cfg), \ patch("hermes_cli.linux_desktop_entry.install_desktop_entry", return_value=None), \ patch("hermes_cli.main.subprocess.run", side_effect=_pack_into_staging(root)) as mock_run2, \ @@ -1157,26 +1159,26 @@ def _clear_keychain_env(monkeypatch): def test_detect_linux_password_store_prefers_kde_session(monkeypatch, kde_version, expected): _clear_keychain_env(monkeypatch) monkeypatch.setenv("KDE_SESSION_VERSION", kde_version) - assert cli_main._detect_linux_password_store() == expected + assert main_desktop._detect_linux_password_store() == expected def test_detect_linux_password_store_kde_full_session(monkeypatch): _clear_keychain_env(monkeypatch) monkeypatch.setenv("KDE_FULL_SESSION", "true") - assert cli_main._detect_linux_password_store() == "kwallet" + assert main_desktop._detect_linux_password_store() == "kwallet" def test_detect_linux_password_store_gnome_keyring(monkeypatch): _clear_keychain_env(monkeypatch) monkeypatch.setenv("GNOME_KEYRING_CONTROL", "/run/user/1000/keyring") - assert cli_main._detect_linux_password_store() == "gnome-libsecret" + assert main_desktop._detect_linux_password_store() == "gnome-libsecret" def test_detect_linux_password_store_via_dbus_secret_service(monkeypatch): _clear_keychain_env(monkeypatch) ping_ok = subprocess.CompletedProcess(["dbus-send"], 0) with patch("hermes_cli.main.subprocess.run", return_value=ping_ok) as mock_run: - assert cli_main._detect_linux_password_store() == "gnome-libsecret" + assert main_desktop._detect_linux_password_store() == "gnome-libsecret" assert "--dest=org.freedesktop.secrets" in mock_run.call_args.args[0] @@ -1184,9 +1186,9 @@ def test_detect_linux_password_store_none_when_no_keychain(monkeypatch): _clear_keychain_env(monkeypatch) ping_fail = subprocess.CompletedProcess(["dbus-send"], 1) with patch("hermes_cli.main.subprocess.run", return_value=ping_fail): - assert cli_main._detect_linux_password_store() is None + assert main_desktop._detect_linux_password_store() is None with patch("hermes_cli.main.subprocess.run", side_effect=FileNotFoundError): - assert cli_main._detect_linux_password_store() is None + assert main_desktop._detect_linux_password_store() is None @pytest.mark.linux_only @@ -1199,14 +1201,14 @@ def test_gui_linux_packaged_launch_bridges_detected_password_store(tmp_path, mon ok = subprocess.CompletedProcess([], 0) with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ - patch("hermes_cli.main._run_npm_install_deterministic", return_value=ok), \ - patch("hermes_cli.main._desktop_build_needed", return_value=True), \ - patch("hermes_cli.main._write_desktop_build_stamp"), \ - patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \ - patch("hermes_cli.main._desktop_linux_sandbox_fixup", return_value=True), \ + patch("hermes_cli.main_web_build._run_npm_install_deterministic", return_value=ok), \ + patch("hermes_cli.main_desktop._desktop_build_needed", return_value=True), \ + patch("hermes_cli.main_desktop._write_desktop_build_stamp"), \ + patch("hermes_cli.main_desktop._desktop_macos_relaunchable_fixup"), \ + patch("hermes_cli.main_desktop._desktop_linux_sandbox_fixup", return_value=True), \ patch("hermes_cli.config.load_config", return_value={}), \ patch("hermes_cli.linux_desktop_entry.install_desktop_entry", return_value=None), \ - patch("hermes_cli.main._detect_linux_password_store", return_value="gnome-libsecret"), \ + patch("hermes_cli.main_desktop._detect_linux_password_store", return_value="gnome-libsecret"), \ patch("hermes_cli.main.subprocess.run", side_effect=_pack_into_staging(root)) as mock_run, \ pytest.raises(SystemExit): cli_main.cmd_gui(_ns()) @@ -1224,12 +1226,12 @@ def test_gui_linux_source_launch_bridges_detected_password_store(tmp_path, monke ok = subprocess.CompletedProcess([], 0) with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ - patch("hermes_cli.main._run_npm_install_deterministic", return_value=ok), \ - patch("hermes_cli.main._desktop_build_needed", return_value=True), \ - patch("hermes_cli.main._write_desktop_build_stamp"), \ + patch("hermes_cli.main_web_build._run_npm_install_deterministic", return_value=ok), \ + patch("hermes_cli.main_desktop._desktop_build_needed", return_value=True), \ + patch("hermes_cli.main_desktop._write_desktop_build_stamp"), \ patch("hermes_cli.config.load_config", return_value={}), \ patch("hermes_cli.linux_desktop_entry.install_desktop_entry", return_value=None), \ - patch("hermes_cli.main._detect_linux_password_store", return_value="kwallet6"), \ + patch("hermes_cli.main_desktop._detect_linux_password_store", return_value="kwallet6"), \ patch("hermes_cli.main.subprocess.run", side_effect=_pack_into_staging(root)) as mock_run, \ pytest.raises(SystemExit): cli_main.cmd_gui(_ns(source=True)) @@ -1250,14 +1252,14 @@ def test_gui_config_password_store_skips_detection(tmp_path, monkeypatch): cfg = {"desktop": {"password_store": "kwallet6"}} with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ - patch("hermes_cli.main._run_npm_install_deterministic", return_value=ok), \ - patch("hermes_cli.main._desktop_build_needed", return_value=True), \ - patch("hermes_cli.main._write_desktop_build_stamp"), \ - patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \ - patch("hermes_cli.main._desktop_linux_sandbox_fixup", return_value=True), \ + patch("hermes_cli.main_web_build._run_npm_install_deterministic", return_value=ok), \ + patch("hermes_cli.main_desktop._desktop_build_needed", return_value=True), \ + patch("hermes_cli.main_desktop._write_desktop_build_stamp"), \ + patch("hermes_cli.main_desktop._desktop_macos_relaunchable_fixup"), \ + patch("hermes_cli.main_desktop._desktop_linux_sandbox_fixup", return_value=True), \ patch("hermes_cli.config.load_config", return_value=cfg), \ patch("hermes_cli.linux_desktop_entry.install_desktop_entry", return_value=None), \ - patch("hermes_cli.main._detect_linux_password_store") as mock_detect, \ + patch("hermes_cli.main_desktop._detect_linux_password_store") as mock_detect, \ patch("hermes_cli.main.subprocess.run", side_effect=_pack_into_staging(root)) as mock_run, \ pytest.raises(SystemExit): cli_main.cmd_gui(_ns()) @@ -1279,14 +1281,14 @@ def test_gui_explicit_password_store_env_wins_over_config_and_detection(tmp_path cfg = {"desktop": {"password_store": "kwallet6"}} with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ - patch("hermes_cli.main._run_npm_install_deterministic", return_value=ok), \ - patch("hermes_cli.main._desktop_build_needed", return_value=True), \ - patch("hermes_cli.main._write_desktop_build_stamp"), \ - patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \ - patch("hermes_cli.main._desktop_linux_sandbox_fixup", return_value=True), \ + patch("hermes_cli.main_web_build._run_npm_install_deterministic", return_value=ok), \ + patch("hermes_cli.main_desktop._desktop_build_needed", return_value=True), \ + patch("hermes_cli.main_desktop._write_desktop_build_stamp"), \ + patch("hermes_cli.main_desktop._desktop_macos_relaunchable_fixup"), \ + patch("hermes_cli.main_desktop._desktop_linux_sandbox_fixup", return_value=True), \ patch("hermes_cli.config.load_config", return_value=cfg), \ patch("hermes_cli.linux_desktop_entry.install_desktop_entry", return_value=None), \ - patch("hermes_cli.main._detect_linux_password_store") as mock_detect, \ + patch("hermes_cli.main_desktop._detect_linux_password_store") as mock_detect, \ patch("hermes_cli.main.subprocess.run", side_effect=_pack_into_staging(root)) as mock_run, \ pytest.raises(SystemExit): cli_main.cmd_gui(_ns()) @@ -1306,13 +1308,13 @@ def test_gui_password_store_bridge_is_linux_only(tmp_path, monkeypatch): ok = subprocess.CompletedProcess([], 0) with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ - patch("hermes_cli.main._run_npm_install_deterministic", return_value=ok), \ - patch("hermes_cli.main._desktop_build_needed", return_value=True), \ - patch("hermes_cli.main._write_desktop_build_stamp"), \ - patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \ + patch("hermes_cli.main_web_build._run_npm_install_deterministic", return_value=ok), \ + patch("hermes_cli.main_desktop._desktop_build_needed", return_value=True), \ + patch("hermes_cli.main_desktop._write_desktop_build_stamp"), \ + patch("hermes_cli.main_desktop._desktop_macos_relaunchable_fixup"), \ patch("hermes_cli.config.load_config", return_value={}), \ patch("hermes_cli.linux_desktop_entry.install_desktop_entry", return_value=None), \ - patch("hermes_cli.main._detect_linux_password_store") as mock_detect, \ + patch("hermes_cli.main_desktop._detect_linux_password_store") as mock_detect, \ patch("hermes_cli.main.subprocess.run", side_effect=_pack_into_staging(root)) as mock_run, \ pytest.raises(SystemExit): cli_main.cmd_gui(_ns()) @@ -1333,15 +1335,15 @@ def test_gui_password_store_bridge_is_linux_only(tmp_path, monkeypatch): def _gui_build_patches(root: Path, run_side_effect): return [ patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), - patch("hermes_cli.main._run_npm_install_deterministic", + patch("hermes_cli.main_web_build._run_npm_install_deterministic", return_value=subprocess.CompletedProcess(["npm", "ci"], 0)), - patch("hermes_cli.main._desktop_build_needed", return_value=True), - patch("hermes_cli.main._write_desktop_build_stamp"), - patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), - patch("hermes_cli.main._register_linux_desktop_entry"), - patch("hermes_cli.main._stop_desktop_processes_locking_build", return_value=[]), - patch("hermes_cli.main._purge_electron_build_cache", return_value=[]), - patch("hermes_cli.main._redownload_electron_dist", return_value=False), + patch("hermes_cli.main_desktop._desktop_build_needed", return_value=True), + patch("hermes_cli.main_desktop._write_desktop_build_stamp"), + patch("hermes_cli.main_desktop._desktop_macos_relaunchable_fixup"), + patch("hermes_cli.main_desktop._register_linux_desktop_entry"), + patch("hermes_cli.main_desktop._stop_desktop_processes_locking_build", return_value=[]), + patch("hermes_cli.main_desktop._purge_electron_build_cache", return_value=[]), + patch("hermes_cli.main_desktop._redownload_electron_dist", return_value=False), patch("hermes_cli.main.subprocess.run", side_effect=run_side_effect), ] @@ -1352,12 +1354,12 @@ def test_swap_staged_desktop_app_promotes_staged_tree_and_drops_previous(tmp_pat live_exe = desktop_dir / "release" / _packaged_exe_rel() live_exe.parent.mkdir(parents=True) live_exe.write_text("old", encoding="utf-8") - staging = cli_main._desktop_staging_dir(desktop_dir) + staging = main_desktop._desktop_staging_dir(desktop_dir) staged_exe = staging / _packaged_exe_rel() staged_exe.parent.mkdir(parents=True) staged_exe.write_text("new", encoding="utf-8") - promoted = cli_main._swap_staged_desktop_app(desktop_dir, staging) + promoted = main_desktop._swap_staged_desktop_app(desktop_dir, staging) assert promoted == live_exe assert live_exe.read_text(encoding="utf-8") == "new" @@ -1372,10 +1374,10 @@ def test_swap_staged_desktop_app_without_staged_exe_keeps_live_app(tmp_path): live_exe = desktop_dir / "release" / _packaged_exe_rel() live_exe.parent.mkdir(parents=True) live_exe.write_text("old", encoding="utf-8") - staging = cli_main._desktop_staging_dir(desktop_dir) + staging = main_desktop._desktop_staging_dir(desktop_dir) (staging / "linux-unpacked" / "resources").mkdir(parents=True) # partial tree, no exe - assert cli_main._swap_staged_desktop_app(desktop_dir, staging) is None + assert main_desktop._swap_staged_desktop_app(desktop_dir, staging) is None assert live_exe.read_text(encoding="utf-8") == "old" assert not staging.exists() @@ -1386,7 +1388,7 @@ def test_swap_staged_desktop_app_rolls_back_when_second_rename_fails(tmp_path, m live_exe = desktop_dir / "release" / _packaged_exe_rel() live_exe.parent.mkdir(parents=True) live_exe.write_text("old", encoding="utf-8") - staging = cli_main._desktop_staging_dir(desktop_dir) + staging = main_desktop._desktop_staging_dir(desktop_dir) staged_exe = staging / _packaged_exe_rel() staged_exe.parent.mkdir(parents=True) staged_exe.write_text("new", encoding="utf-8") @@ -1401,7 +1403,7 @@ def test_swap_staged_desktop_app_rolls_back_when_second_rename_fails(tmp_path, m return real_rename(src, dst) monkeypatch.setattr(cli_main.os, "rename", flaky_rename) - assert cli_main._swap_staged_desktop_app(desktop_dir, staging) is None + assert main_desktop._swap_staged_desktop_app(desktop_dir, staging) is None assert live_exe.read_text(encoding="utf-8") == "old" assert not (live_exe.parent.parent / (live_exe.parent.name + ".previous")).exists() diff --git a/tests/hermes_cli/test_lazy_command_exports.py b/tests/hermes_cli/test_lazy_command_exports.py index 7f2da36c13..2214ee1429 100644 --- a/tests/hermes_cli/test_lazy_command_exports.py +++ b/tests/hermes_cli/test_lazy_command_exports.py @@ -1,16 +1,18 @@ -"""The decomposed command modules stay lazy after `import hermes_cli.main`. +"""The frozen updater surface on hermes_cli.main stays lazy and resolvable. -The main.py decomposition re-exports the sessions/update/dashboard command -surface from hermes_cli.main so argparse wiring and monkeypatches keep -resolving. Those re-exports must not import the modules eagerly: every -`hermes` invocation (including `hermes --version`) would pay for update_cmd's -dependency chain (jwt, click, ...) even when no subcommand runs. +``hermes_cli/update_cmd*.py`` (frozen: old installed versions call into it) reads +helpers off ``hermes_cli.main`` via ``_m().``. main.py resolves the ones that +live in the lazily-imported command modules through PEP 562 ``__getattr__`` so +every ``hermes`` invocation (including ``hermes --version``) does not pay for +update_cmd's dependency chain (jwt, click, ...) when no subcommand runs. """ import subprocess import sys import textwrap +import pytest + import hermes_cli.main @@ -40,27 +42,34 @@ def test_importing_main_does_not_import_command_modules(): assert result.returncode == 0, result.stderr -def test_lazy_reexports_resolve_to_real_objects(): - import hermes_cli.dashboard_procs - import hermes_cli.sessions_cmd - import hermes_cli.update_cmd - - assert hermes_cli.main.cmd_sessions is hermes_cli.sessions_cmd.cmd_sessions - assert ( - hermes_cli.main._cmd_update_impl is hermes_cli.update_cmd._cmd_update_impl - ) - assert ( - hermes_cli.main._scan_dashboard_processes - is hermes_cli.dashboard_procs._scan_dashboard_processes - ) - # Back-compat alias resolves to the kill helper. - assert ( - hermes_cli.main._warn_stale_dashboard_processes - is hermes_cli.dashboard_procs._kill_stale_dashboard_processes - ) +@pytest.mark.real_concurrent_gate # conftest autouse stub would shadow one frozen name +def test_frozen_updater_surface_resolves_to_real_objects(): + for module, names in hermes_cli.main._FROZEN_UPDATER_SURFACE.items(): + mod = sys.modules[module] if module in sys.modules else __import__(module, fromlist=["_"]) + for name in names: + got = getattr(hermes_cli.main, name) + # Identity, or the same function after another test importlib.reload()ed the module + # (the resolved value is cached on hermes_cli.main by design). + assert got is getattr(mod, name) or ( + getattr(got, "__module__", None) == module and getattr(got, "__name__", None) == name + ), name + assert "_kill_stale_dashboard_processes" in hermes_cli.main._FROZEN_UPDATER_SURFACE["hermes_cli.dashboard_procs"] + assert "_stash_local_changes_if_needed" in hermes_cli.main._FROZEN_UPDATER_SURFACE["hermes_cli.update_cmd"] -def test_lazy_reexports_accept_monkeypatch(monkeypatch): - sentinel = object() - monkeypatch.setattr("hermes_cli.main._cmd_update_impl", sentinel) - assert hermes_cli.main._cmd_update_impl is sentinel +def test_frozen_surface_covers_every_update_cmd_main_read(): + """Every ``_m().`` in the frozen update_cmd*.py files resolves on hermes_cli.main.""" + import re + from pathlib import Path + + pkg = Path(hermes_cli.main.__file__).parent + names = set() + for path in pkg.glob("update*.py"): + names.update(re.findall(r"_m\(\)\.(\w+)", path.read_text(encoding="utf-8"))) + missing = [n for n in sorted(names) if not hasattr(hermes_cli.main, n)] + assert not missing, missing + + +def test_removed_reexports_are_gone(): + for name in ("_scan_dashboard_processes", "_warn_stale_dashboard_processes", "_self", "_PROVIDER_MODELS"): + assert not hasattr(hermes_cli.main, name), name diff --git a/tests/hermes_cli/test_lazy_refresh_venv_repair.py b/tests/hermes_cli/test_lazy_refresh_venv_repair.py index f71c82515c..e643691153 100644 --- a/tests/hermes_cli/test_lazy_refresh_venv_repair.py +++ b/tests/hermes_cli/test_lazy_refresh_venv_repair.py @@ -8,6 +8,8 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch import hermes_cli.main as m +from hermes_cli import main_install_repair +from hermes_cli import update_cmd import pytest @@ -26,7 +28,7 @@ def test_detect_returns_none_when_probe_subprocess_fails(tmp_path, monkeypatch): "run", MagicMock(side_effect=OSError("exec failed")), ) - assert m._detect_broken_lazy_refresh_imports(["uv", "pip"]) is None + assert main_install_repair._detect_broken_lazy_refresh_imports(["uv", "pip"]) is None @@ -61,10 +63,10 @@ def test_repair_runs_force_reinstall_with_pyproject_pins( detect_calls["count"] += 1 return [] - monkeypatch.setattr(m, "_run_package_only_install", fake_install) - monkeypatch.setattr(m, "_detect_broken_lazy_refresh_imports", fake_detect) + monkeypatch.setattr(main_install_repair, "_run_package_only_install", fake_install) + monkeypatch.setattr(main_install_repair, "_detect_broken_lazy_refresh_imports", fake_detect) - ok = m._repair_broken_lazy_refresh_imports( + ok = main_install_repair._repair_broken_lazy_refresh_imports( ["uv", "pip"], ["PyYAML", "click"], env={"VIRTUAL_ENV": str(tmp_path)}, @@ -99,8 +101,8 @@ def test_refresh_repairs_venv_after_lazy_failure(tmp_path, monkeypatch, capsys): repair_calls.append(packages) return True - monkeypatch.setattr(m, "_detect_broken_lazy_refresh_imports", lambda *a, **k: ["PyYAML"]) - monkeypatch.setattr(m, "_repair_broken_lazy_refresh_imports", fake_repair) + monkeypatch.setattr(main_install_repair, "_detect_broken_lazy_refresh_imports", lambda *a, **k: ["PyYAML"]) + monkeypatch.setattr(main_install_repair, "_repair_broken_lazy_refresh_imports", fake_repair) ok = m._refresh_active_lazy_features(["uv", "pip"], env={"VIRTUAL_ENV": str(tmp_path)}) out = capsys.readouterr().out @@ -230,7 +232,7 @@ def test_cmd_update_captures_and_propagates_pre_rebuild_snapshot( branch=None, ) with pytest.raises(RestoreReached): - m._cmd_update_impl(args, gateway_mode=False) + update_cmd._cmd_update_impl(args, gateway_mode=False) # The repair env is now built via managed_python_env (#83914): third-party # UV vars are stripped, managed pins set, then VIRTUAL_ENV re-pointed at diff --git a/tests/hermes_cli/test_linux_sandbox_fixup.py b/tests/hermes_cli/test_linux_sandbox_fixup.py index d1c820b536..37d9d9107d 100644 --- a/tests/hermes_cli/test_linux_sandbox_fixup.py +++ b/tests/hermes_cli/test_linux_sandbox_fixup.py @@ -16,25 +16,25 @@ import subprocess import sys from unittest.mock import patch -from hermes_cli import main as cli_main +from hermes_cli import main_desktop class TestDesktopLinuxUsernsSandboxAvailable: def test_false_on_non_linux(self, monkeypatch): monkeypatch.setattr(sys, "platform", "darwin") - assert cli_main._desktop_linux_userns_sandbox_available() is False + assert main_desktop._desktop_linux_userns_sandbox_available() is False def test_false_when_unshare_is_missing(self, monkeypatch): monkeypatch.setattr(sys, "platform", "linux") - with patch.object(cli_main.shutil, "which", return_value=None): - assert cli_main._desktop_linux_userns_sandbox_available() is False + with patch.object(main_desktop.shutil, "which", return_value=None): + assert main_desktop._desktop_linux_userns_sandbox_available() is False def test_true_when_probe_succeeds(self, monkeypatch): monkeypatch.setattr(sys, "platform", "linux") - with patch.object(cli_main.shutil, "which", return_value="/usr/bin/unshare"), \ - patch.object(cli_main.subprocess, "run") as run: + with patch.object(main_desktop.shutil, "which", return_value="/usr/bin/unshare"), \ + patch.object(main_desktop.subprocess, "run") as run: run.return_value.returncode = 0 - assert cli_main._desktop_linux_userns_sandbox_available() is True + assert main_desktop._desktop_linux_userns_sandbox_available() is True probe = run.call_args.args[0] assert probe[0] == "/usr/bin/unshare" assert "--user" in probe @@ -42,20 +42,20 @@ class TestDesktopLinuxUsernsSandboxAvailable: def test_false_when_probe_fails(self, monkeypatch): """EPERM from the kernel (userns disabled or AppArmor-restricted).""" monkeypatch.setattr(sys, "platform", "linux") - with patch.object(cli_main.shutil, "which", return_value="/usr/bin/unshare"), \ - patch.object(cli_main.subprocess, "run") as run: + with patch.object(main_desktop.shutil, "which", return_value="/usr/bin/unshare"), \ + patch.object(main_desktop.subprocess, "run") as run: run.return_value.returncode = 1 - assert cli_main._desktop_linux_userns_sandbox_available() is False + assert main_desktop._desktop_linux_userns_sandbox_available() is False def test_false_when_probe_raises(self, monkeypatch): monkeypatch.setattr(sys, "platform", "linux") - with patch.object(cli_main.shutil, "which", return_value="/usr/bin/unshare"), \ + with patch.object(main_desktop.shutil, "which", return_value="/usr/bin/unshare"), \ patch.object( - cli_main.subprocess, + main_desktop.subprocess, "run", side_effect=subprocess.TimeoutExpired(cmd="unshare", timeout=5), ): - assert cli_main._desktop_linux_userns_sandbox_available() is False + assert main_desktop._desktop_linux_userns_sandbox_available() is False class TestDesktopLinuxSandboxFixup: @@ -79,10 +79,10 @@ class TestDesktopLinuxSandboxFixup: monkeypatch.setattr(sys, "platform", "linux") exe = self._fake_packaged_app(tmp_path) with patch.object( - cli_main, "_desktop_linux_userns_sandbox_available", return_value=True + main_desktop, "_desktop_linux_userns_sandbox_available", return_value=True ), \ - patch.object(cli_main.subprocess, "run") as run: - assert cli_main._desktop_linux_sandbox_fixup(exe) is True + patch.object(main_desktop.subprocess, "run") as run: + assert main_desktop._desktop_linux_sandbox_fixup(exe) is True run.assert_not_called() def test_restricted_host_without_sudo_still_fails(self, monkeypatch, tmp_path): @@ -90,10 +90,10 @@ class TestDesktopLinuxSandboxFixup: monkeypatch.setattr(sys, "platform", "linux") exe = self._fake_packaged_app(tmp_path) with patch.object( - cli_main, "_desktop_linux_userns_sandbox_available", return_value=False + main_desktop, "_desktop_linux_userns_sandbox_available", return_value=False ), \ - patch.object(cli_main.shutil, "which", return_value=None): - assert cli_main._desktop_linux_sandbox_fixup(exe) is False + patch.object(main_desktop.shutil, "which", return_value=None): + assert main_desktop._desktop_linux_sandbox_fixup(exe) is False def test_root_owned_setuid_helper_short_circuits(self, monkeypatch, tmp_path): """A correctly configured helper wins before the userns probe runs.""" @@ -108,11 +108,11 @@ class TestDesktopLinuxSandboxFixup: def __getattr__(self, name): return getattr(real_lstat, name) - with patch.object(cli_main.Path, "lstat", return_value=_RootSetuidStat()), \ + with patch.object(main_desktop.Path, "lstat", return_value=_RootSetuidStat()), \ patch.object( - cli_main, "_desktop_linux_userns_sandbox_available" + main_desktop, "_desktop_linux_userns_sandbox_available" ) as probe: - assert cli_main._desktop_linux_sandbox_fixup(exe) is True + assert main_desktop._desktop_linux_sandbox_fixup(exe) is True probe.assert_not_called() @@ -131,9 +131,9 @@ class TestDesktopLinuxNeedsDisableSetuidSandbox: monkeypatch.setattr(sys, "platform", "linux") exe = self._fake_packaged_app(tmp_path) with patch.object( - cli_main, "_desktop_linux_userns_sandbox_available", return_value=True + main_desktop, "_desktop_linux_userns_sandbox_available", return_value=True ): - assert cli_main._desktop_linux_needs_disable_setuid_sandbox(exe) is True + assert main_desktop._desktop_linux_needs_disable_setuid_sandbox(exe) is True def test_false_for_root_owned_setuid_helper(self, monkeypatch, tmp_path): monkeypatch.setattr(sys, "platform", "linux") @@ -147,11 +147,11 @@ class TestDesktopLinuxNeedsDisableSetuidSandbox: def __getattr__(self, name): return getattr(real_lstat, name) - with patch.object(cli_main.Path, "lstat", return_value=_RootSetuidStat()), \ + with patch.object(main_desktop.Path, "lstat", return_value=_RootSetuidStat()), \ patch.object( - cli_main, "_desktop_linux_userns_sandbox_available", return_value=True + main_desktop, "_desktop_linux_userns_sandbox_available", return_value=True ) as probe: - assert cli_main._desktop_linux_needs_disable_setuid_sandbox(exe) is False + assert main_desktop._desktop_linux_needs_disable_setuid_sandbox(exe) is False probe.assert_not_called() def test_false_when_helper_missing(self, monkeypatch, tmp_path): @@ -160,4 +160,4 @@ class TestDesktopLinuxNeedsDisableSetuidSandbox: unpacked.mkdir() exe = unpacked / "Hermes" exe.write_text("", encoding="utf-8") - assert cli_main._desktop_linux_needs_disable_setuid_sandbox(exe) is False + assert main_desktop._desktop_linux_needs_disable_setuid_sandbox(exe) is False diff --git a/tests/hermes_cli/test_model_flow_pooled_credentials.py b/tests/hermes_cli/test_model_flow_pooled_credentials.py index 000a5a71fa..9682afdae6 100644 --- a/tests/hermes_cli/test_model_flow_pooled_credentials.py +++ b/tests/hermes_cli/test_model_flow_pooled_credentials.py @@ -46,7 +46,7 @@ def test_generic_api_key_flow_passes_pool_key_to_existing_key_prompt(monkeypatch with ( patch("hermes_cli.config.get_env_value", return_value=""), patch("agent.credential_pool.load_pool", return_value=_AvailablePool()), - patch("hermes_cli.main._prompt_api_key", side_effect=capture_prompt), + patch("hermes_cli.main_provider_setup._prompt_api_key", side_effect=capture_prompt), ): _model_flow_api_key_provider({}, "deepseek") diff --git a/tests/hermes_cli/test_model_provider_persistence.py b/tests/hermes_cli/test_model_provider_persistence.py index 6a77447c53..a18d5259fd 100644 --- a/tests/hermes_cli/test_model_provider_persistence.py +++ b/tests/hermes_cli/test_model_provider_persistence.py @@ -92,7 +92,7 @@ class TestProviderPersistsAfterModelSave: # Simulate: user has a Kimi API key, model was a string monkeypatch.setenv("KIMI_API_KEY", "sk-kimi-test-key") - from hermes_cli.main import _model_flow_api_key_provider + from hermes_cli.model_setup_flows import _model_flow_api_key_provider from hermes_cli.config import load_config # Mock the model selection prompt to return "kimi-k2.5" @@ -138,7 +138,7 @@ class TestBaseUrlValidation: monkeypatch.setenv("MINIMAX_API_KEY", "test-key") monkeypatch.delenv("MINIMAX_BASE_URL", raising=False) - from hermes_cli.main import _model_flow_api_key_provider + from hermes_cli.model_setup_flows import _model_flow_api_key_provider from hermes_cli.config import load_config, get_env_value with patch("hermes_cli.auth._prompt_model_selection", return_value="MiniMax-M2"), \ @@ -157,7 +157,7 @@ class TestZaiEndpointPicker: def test_custom_proxy_rejects_invalid_url(self, config_home, monkeypatch, capsys): """Custom proxy must start with http:// or https://.""" - from hermes_cli.main import _model_flow_api_key_provider + from hermes_cli.model_setup_flows import _model_flow_api_key_provider from hermes_cli.config import load_config monkeypatch.setenv("GLM_API_KEY", "test-key") diff --git a/tests/hermes_cli/test_model_switch_custom_providers.py b/tests/hermes_cli/test_model_switch_custom_providers.py index 259302d211..3fe2511766 100644 --- a/tests/hermes_cli/test_model_switch_custom_providers.py +++ b/tests/hermes_cli/test_model_switch_custom_providers.py @@ -1560,7 +1560,7 @@ def test_model_flow_named_custom_persists_discovered_models(monkeypatch): "hermes_cli.curses_ui.curses_radiolist", lambda *a, **k: 0 ) # No-op downstream writes so the test never touches a real config. - monkeypatch.setattr("hermes_cli.main._save_custom_provider", lambda *a, **k: None) + monkeypatch.setattr("hermes_cli.main_provider_setup._save_custom_provider", lambda *a, **k: None) monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda *a, **k: None) monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda *a, **k: None) monkeypatch.setattr( diff --git a/tests/hermes_cli/test_models.py b/tests/hermes_cli/test_models.py index 717940271a..ce6f25aa21 100644 --- a/tests/hermes_cli/test_models.py +++ b/tests/hermes_cli/test_models.py @@ -944,7 +944,7 @@ class TestLocalOllamaModelDiscovery: def test_named_custom_model_flow_discovers_ollama_api_tags(self): """Interactive named-custom setup should use tags for a local Ollama root.""" - from hermes_cli.main import _model_flow_named_custom + from hermes_cli.model_setup_flows import _model_flow_named_custom server, port = _start_fake_ollama_server() base_url = f"http://127.0.0.1:{port}/v1" @@ -960,7 +960,7 @@ class TestLocalOllamaModelDiscovery: "hermes_cli.config.save_config" ), patch("hermes_cli.auth._save_model_choice"), patch( "hermes_cli.auth.deactivate_provider" - ), patch("hermes_cli.main._save_custom_provider"), patch( + ), patch("hermes_cli.main_provider_setup._save_custom_provider"), patch( "hermes_cli.curses_ui.curses_radiolist", side_effect=cancel_after_capturing_models, ), patch("builtins.input", return_value="manual-fallback"), patch( @@ -979,7 +979,7 @@ class TestLocalOllamaModelDiscovery: def test_named_custom_model_flow_preserves_explicit_ollama_models(self): """An explicit named-custom models list should skip live native tags.""" - from hermes_cli.main import _model_flow_named_custom + from hermes_cli.model_setup_flows import _model_flow_named_custom server, port = _start_fake_ollama_server() base_url = f"http://127.0.0.1:{port}/v1" diff --git a/tests/hermes_cli/test_models_dev_preferred_merge.py b/tests/hermes_cli/test_models_dev_preferred_merge.py index bdfb4c3857..095f9d6769 100644 --- a/tests/hermes_cli/test_models_dev_preferred_merge.py +++ b/tests/hermes_cli/test_models_dev_preferred_merge.py @@ -125,7 +125,7 @@ class TestProviderModelIdsPreferred: return None with ( - patch("hermes_cli.main._prompt_api_key", return_value=("sk-kimi-test", False)), + patch("hermes_cli.main_provider_setup._prompt_api_key", return_value=("sk-kimi-test", False)), patch("hermes_cli.auth._prompt_model_selection", side_effect=fake_select), patch("hermes_cli.config.get_env_value", return_value=""), patch("hermes_cli.config.save_env_value"), diff --git a/tests/hermes_cli/test_pin_kanban_board_env.py b/tests/hermes_cli/test_pin_kanban_board_env.py index ceb24e855a..d9ecf49cf6 100644 --- a/tests/hermes_cli/test_pin_kanban_board_env.py +++ b/tests/hermes_cli/test_pin_kanban_board_env.py @@ -11,6 +11,7 @@ import importlib import os import pytest +from hermes_cli import main_tui_launch @pytest.fixture(autouse=True) @@ -39,7 +40,7 @@ def test_pin_writes_resolved_board_when_env_unset(monkeypatch): import hermes_cli.kanban_db as kdb monkeypatch.setattr(kdb, "get_current_board", lambda: "space") - main_mod._pin_kanban_board_env() + main_tui_launch._pin_kanban_board_env() assert main_mod.os.environ.get("HERMES_KANBAN_BOARD") == "space" @@ -55,7 +56,7 @@ def test_pin_does_not_overwrite_existing_env(monkeypatch): monkeypatch.setattr(kdb, "get_current_board", _explode) - main_mod._pin_kanban_board_env() + main_tui_launch._pin_kanban_board_env() assert main_mod.os.environ.get("HERMES_KANBAN_BOARD") == "preset" diff --git a/tests/hermes_cli/test_plugin_auxiliary_tasks.py b/tests/hermes_cli/test_plugin_auxiliary_tasks.py index e27098d170..e463cc5824 100644 --- a/tests/hermes_cli/test_plugin_auxiliary_tasks.py +++ b/tests/hermes_cli/test_plugin_auxiliary_tasks.py @@ -95,7 +95,7 @@ def test_register_auxiliary_task_basic(): def test_all_aux_tasks_includes_plugin_registered(patched_manager): - from hermes_cli.main import _AUX_TASKS, _all_aux_tasks + from hermes_cli.main_provider_setup import _AUX_TASKS, _all_aux_tasks manifest = PluginManifest(name="hindsight") ctx = PluginContext(manifest, patched_manager) @@ -129,7 +129,7 @@ def test_reset_aux_to_auto_resets_plugin_tasks(tmp_path, monkeypatch, patched_ma """Plugin task with non-auto config gets reset alongside built-ins.""" from pathlib import Path from hermes_cli.config import load_config, save_config - from hermes_cli.main import _reset_aux_to_auto + from hermes_cli.main_provider_setup import _reset_aux_to_auto monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) monkeypatch.setattr(Path, "home", lambda: tmp_path) diff --git a/tests/hermes_cli/test_process_identity.py b/tests/hermes_cli/test_process_identity.py index 144a12de2e..f250b394c7 100644 --- a/tests/hermes_cli/test_process_identity.py +++ b/tests/hermes_cli/test_process_identity.py @@ -23,6 +23,7 @@ from unittest.mock import MagicMock, patch import pytest from hermes_cli import process_identity as pi +from hermes_cli import update_cmd class _FakeNoSuchProcess(Exception): diff --git a/tests/hermes_cli/test_profile_export_default_path.py b/tests/hermes_cli/test_profile_export_default_path.py index 6795cd5ec4..23092e7250 100644 --- a/tests/hermes_cli/test_profile_export_default_path.py +++ b/tests/hermes_cli/test_profile_export_default_path.py @@ -7,6 +7,7 @@ from argparse import Namespace from pathlib import Path import pytest +from hermes_cli import profile_cmd @pytest.fixture() @@ -88,7 +89,7 @@ def test_cli_export_rejects_bad_profile_name_without_traceback( monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home) with pytest.raises(SystemExit): - main_mod.cmd_profile( + profile_cmd.cmd_profile( Namespace( profile_action="export", profile_name="bad//name", @@ -115,7 +116,7 @@ def test_cli_export_default_does_not_write_into_the_current_checkout( "hermes_constants.get_default_hermes_root", lambda: default_home ) - main_mod.cmd_profile( + profile_cmd.cmd_profile( Namespace( profile_action="export", profile_name="default", diff --git a/tests/hermes_cli/test_profile_install_env_encoding.py b/tests/hermes_cli/test_profile_install_env_encoding.py index f6e746c86f..65bd4db235 100644 --- a/tests/hermes_cli/test_profile_install_env_encoding.py +++ b/tests/hermes_cli/test_profile_install_env_encoding.py @@ -18,7 +18,7 @@ from types import SimpleNamespace import pytest -from hermes_cli.main import _render_distribution_plan +from hermes_cli.profile_cmd import _render_distribution_plan def _make_plan(target_dir, env_requires): diff --git a/tests/hermes_cli/test_prompt_api_key.py b/tests/hermes_cli/test_prompt_api_key.py index d91fb7b74c..398ecac4ae 100644 --- a/tests/hermes_cli/test_prompt_api_key.py +++ b/tests/hermes_cli/test_prompt_api_key.py @@ -10,6 +10,7 @@ from pathlib import Path from unittest.mock import patch import pytest +from hermes_cli import main_provider_setup @pytest.fixture @@ -34,7 +35,7 @@ def _run_prompt(existing_key, choice, new_key="", provider_id="", pconfig_name=" pconfig = _pconfig(pconfig_name) with patch("builtins.input", return_value=choice), \ patch("hermes_cli.secret_prompt.masked_secret_prompt", return_value=new_key): - return m._prompt_api_key(pconfig, existing_key, provider_id=provider_id) + return main_provider_setup._prompt_api_key(pconfig, existing_key, provider_id=provider_id) def test_pool_only_key_does_not_offer_or_execute_clear(profile_env, monkeypatch, capsys): @@ -49,7 +50,7 @@ def test_pool_only_key_does_not_offer_or_execute_clear(profile_env, monkeypatch, monkeypatch.setattr("builtins.input", choose_clear) with patch("hermes_cli.config.save_env_value") as save_env: - key, abort = m._prompt_api_key( + key, abort = main_provider_setup._prompt_api_key( pconfig, "pool-secret", provider_id="deepseek", diff --git a/tests/hermes_cli/test_psutil_android_extract.py b/tests/hermes_cli/test_psutil_android_extract.py index 86477e427c..64e57f3cd0 100644 --- a/tests/hermes_cli/test_psutil_android_extract.py +++ b/tests/hermes_cli/test_psutil_android_extract.py @@ -17,6 +17,7 @@ from hermes_cli.psutil_android import ( PsutilAndroidInstallError, prepare_patched_psutil_sdist, ) +from hermes_cli import update_cmd def _add_dir(tf: tarfile.TarFile, name: str) -> None: @@ -87,7 +88,7 @@ def test_install_psutil_android_compat_uses_patched_tree(tmp_path): with patch("urllib.request.urlretrieve", side_effect=fake_urlretrieve), \ patch.object(hermes_main, "_run_install_with_heartbeat", side_effect=fake_run_install): - hermes_main._install_psutil_android_compat( + update_cmd._install_psutil_android_compat( ["uv", "pip"], env={"HERMES_TEST": "1"}, ) diff --git a/tests/hermes_cli/test_quarantine_noop_restore.py b/tests/hermes_cli/test_quarantine_noop_restore.py index 9893925f38..076ee35741 100644 --- a/tests/hermes_cli/test_quarantine_noop_restore.py +++ b/tests/hermes_cli/test_quarantine_noop_restore.py @@ -23,7 +23,7 @@ from unittest.mock import patch import pytest from hermes_cli import _install_repair as ir -from hermes_cli import main as cli_main +from hermes_cli import main_install_repair def _make_scripts_dir(tmp_path: Path) -> Path: @@ -39,7 +39,7 @@ def _shim_names(scripts: Path) -> set[str]: # --------------------------------------------------------------------------- -# hermes_cli.main._run_quarantined_install +# main_install_repair._run_quarantined_install # --------------------------------------------------------------------------- @@ -47,10 +47,9 @@ def test_main_noop_success_restores_shims(tmp_path): """A successful install that writes no entry points must restore shims.""" scripts = _make_scripts_dir(tmp_path) - with patch.object(cli_main, "_is_windows", lambda: True), patch.object( - cli_main, "_run_install_with_heartbeat", lambda cmd, env=None: None + with patch.object(main_install_repair, "_is_windows", lambda: True), patch.object(main_install_repair, "_run_install_with_heartbeat", lambda cmd, env=None: None ): - cli_main._run_quarantined_install(["fake"], scripts_dir=scripts) + main_install_repair._run_quarantined_install(["fake"], scripts_dir=scripts) names = _shim_names(scripts) assert "hermes.exe" in names, "hermes.exe must be restored after a no-op install" @@ -67,10 +66,9 @@ def test_main_rewriting_success_keeps_fresh_shims(tmp_path): for name in ("hermes", "hermes-agent", "hermes-acp", "hermes-gateway"): (scripts / f"{name}.exe").write_bytes(b"MZ-new-" + name.encode()) - with patch.object(cli_main, "_is_windows", lambda: True), patch.object( - cli_main, "_run_install_with_heartbeat", fake_install + with patch.object(main_install_repair, "_is_windows", lambda: True), patch.object(main_install_repair, "_run_install_with_heartbeat", fake_install ): - cli_main._run_quarantined_install(["fake"], scripts_dir=scripts) + main_install_repair._run_quarantined_install(["fake"], scripts_dir=scripts) assert (scripts / "hermes.exe").read_bytes() == b"MZ-new-hermes" @@ -81,11 +79,10 @@ def test_main_failure_restores_shims_and_reraises(tmp_path): def boom(cmd, env=None): raise RuntimeError("install died") - with patch.object(cli_main, "_is_windows", lambda: True), patch.object( - cli_main, "_run_install_with_heartbeat", boom + with patch.object(main_install_repair, "_is_windows", lambda: True), patch.object(main_install_repair, "_run_install_with_heartbeat", boom ): with pytest.raises(RuntimeError, match="install died"): - cli_main._run_quarantined_install(["fake"], scripts_dir=scripts) + main_install_repair._run_quarantined_install(["fake"], scripts_dir=scripts) assert (scripts / "hermes.exe").read_bytes() == b"MZ-old-hermes" diff --git a/tests/hermes_cli/test_quarantine_orphan_rescue.py b/tests/hermes_cli/test_quarantine_orphan_rescue.py index aef0704c7a..28c029cc78 100644 --- a/tests/hermes_cli/test_quarantine_orphan_rescue.py +++ b/tests/hermes_cli/test_quarantine_orphan_rescue.py @@ -30,6 +30,7 @@ import pytest from hermes_cli import _early_recovery as er from hermes_cli import _install_repair as ir from hermes_cli import main as cli_main +from hermes_cli import main_install_repair def _make_scripts_dir(tmp_path: Path) -> Path: @@ -49,7 +50,7 @@ def _run_cleanup(scripts: Path): it has its own tests and must not run here. """ return patch.multiple( - cli_main, + main_install_repair, _is_windows=lambda: True, _cleanup_pending_shim_renames=lambda _scripts_dir: 0, ) @@ -213,7 +214,7 @@ def test_cleanup_still_sweeps_genuinely_stale_quarantine(tmp_path): """Past the grace window, with the shim present, it's garbage — sweep it.""" scripts = _make_scripts_dir(tmp_path) (scripts / "hermes.exe").write_bytes(b"MZ-live") - ancient_ms = (cli_main._QUARANTINE_GRACE_SECONDS + 60) * 1000 + ancient_ms = (main_install_repair._QUARANTINE_GRACE_SECONDS + 60) * 1000 stale = scripts / f"hermes.exe.old.{_stamp(ancient_ms)}" stale.write_bytes(b"MZ-stale") @@ -240,9 +241,9 @@ def test_cleanup_age_comes_from_filename_not_mtime(tmp_path): def test_quarantine_stamp_ms_parses_and_rejects(): - assert cli_main._quarantine_stamp_ms(Path("hermes.exe.old.1787020473885")) == 1787020473885 - assert cli_main._quarantine_stamp_ms(Path("hermes.exe.old.backup")) is None - assert cli_main._quarantine_stamp_ms(Path("hermes.exe")) is None + assert main_install_repair._quarantine_stamp_ms(Path("hermes.exe.old.1787020473885")) == 1787020473885 + assert main_install_repair._quarantine_stamp_ms(Path("hermes.exe.old.backup")) is None + assert main_install_repair._quarantine_stamp_ms(Path("hermes.exe")) is None # --------------------------------------------------------------------------- @@ -322,7 +323,7 @@ def test_main_restore_reports_on_stderr(tmp_path, capsys): raise PermissionError(32, "being used by another process") with patch.object(er.os, "rename", always_locked): - cli_main._restore_quarantined_exes([(original, quarantined)]) + main_install_repair._restore_quarantined_exes([(original, quarantined)]) captured = capsys.readouterr() assert "FAILED to restore hermes.exe" in captured.err diff --git a/tests/hermes_cli/test_reasoning_effort_menu.py b/tests/hermes_cli/test_reasoning_effort_menu.py index 79063587f0..84bf492060 100644 --- a/tests/hermes_cli/test_reasoning_effort_menu.py +++ b/tests/hermes_cli/test_reasoning_effort_menu.py @@ -1,4 +1,4 @@ -from hermes_cli.main import _prompt_reasoning_effort_selection +from hermes_cli.main_provider_setup import _prompt_reasoning_effort_selection def test_reasoning_menu_orders_minimal_before_low(monkeypatch): diff --git a/tests/hermes_cli/test_run_with_idle_timeout.py b/tests/hermes_cli/test_run_with_idle_timeout.py index 4bb9703ae9..f4ce0de919 100644 --- a/tests/hermes_cli/test_run_with_idle_timeout.py +++ b/tests/hermes_cli/test_run_with_idle_timeout.py @@ -14,7 +14,7 @@ fixes both halves — streams output AND idle-kills the process. import sys as _sys import time -from hermes_cli.main import _run_with_idle_timeout +from hermes_cli.main_web_build import _run_with_idle_timeout def test_streams_output_and_returns_zero_on_success(tmp_path): diff --git a/tests/hermes_cli/test_session_browse.py b/tests/hermes_cli/test_session_browse.py index 16ccf384b0..45622a8c21 100644 --- a/tests/hermes_cli/test_session_browse.py +++ b/tests/hermes_cli/test_session_browse.py @@ -10,7 +10,7 @@ import time from unittest.mock import MagicMock, patch -from hermes_cli.main import _session_browse_picker +from hermes_cli.sessions_cmd_browse import _session_browse_picker # ─── Sample session data ────────────────────────────────────────────────────── @@ -159,7 +159,7 @@ class TestSessionBrowseArgparse: # by checking that argparse doesn't error on "sessions browse" # Re-create the parser portion # Instead, let's just verify the import works and the function exists - from hermes_cli.main import _session_browse_picker + from hermes_cli.sessions_cmd_browse import _session_browse_picker assert callable(_session_browse_picker) def test_browse_default_limit_is_500(self): diff --git a/tests/hermes_cli/test_sessions_size_delta_label.py b/tests/hermes_cli/test_sessions_size_delta_label.py index 0d943bf7a2..286898900c 100644 --- a/tests/hermes_cli/test_sessions_size_delta_label.py +++ b/tests/hermes_cli/test_sessions_size_delta_label.py @@ -4,7 +4,7 @@ A negative before/after delta means the DB grew — printing "reclaimed -163.0 MB" for that reads as data loss (issue #70146). """ -from hermes_cli.main import _size_delta_label +from hermes_cli.sessions_cmd import _size_delta_label def test_shrink_reports_reclaimed(): diff --git a/tests/hermes_cli/test_shim_fail_closed_windows_live.py b/tests/hermes_cli/test_shim_fail_closed_windows_live.py index a3474fc10a..9c109a8f9f 100644 --- a/tests/hermes_cli/test_shim_fail_closed_windows_live.py +++ b/tests/hermes_cli/test_shim_fail_closed_windows_live.py @@ -19,6 +19,7 @@ import time from pathlib import Path import pytest +from hermes_cli import main_install_repair pytestmark = pytest.mark.skipif( sys.platform != "win32", reason="live Windows shim-lock E2E" @@ -83,8 +84,8 @@ def test_strict_quarantine_refuses_against_real_lock(held_shim, monkeypatch): lambda cmd, env=None: install_ran.append(cmd), ) - with pytest.raises(cli_main.ShimQuarantineError) as exc_info: - cli_main._run_quarantined_install( + with pytest.raises(main_install_repair.ShimQuarantineError) as exc_info: + main_install_repair._run_quarantined_install( ["would-be", "uv", "pip", "install"], scripts_dir=scripts, strict_quarantine=True, @@ -134,7 +135,7 @@ def test_release_then_strict_quarantine_succeeds(tmp_path, monkeypatch): "_run_install_with_heartbeat", lambda cmd, env=None: install_ran.append(cmd), ) - cli_main._run_quarantined_install( + main_install_repair._run_quarantined_install( ["fake"], scripts_dir=scripts, strict_quarantine=True ) assert install_ran == [["fake"]] diff --git a/tests/hermes_cli/test_slack_cli.py b/tests/hermes_cli/test_slack_cli.py index 51d370f6aa..db1f36f344 100644 --- a/tests/hermes_cli/test_slack_cli.py +++ b/tests/hermes_cli/test_slack_cli.py @@ -10,6 +10,7 @@ import pytest from hermes_cli.slack_cli import _build_full_manifest, slack_manifest_command from hermes_cli.subcommands.slack import build_slack_parser +from hermes_cli import main_platform_setup def _parse_slack_args(argv): diff --git a/tests/hermes_cli/test_ssh_session_token_parser.py b/tests/hermes_cli/test_ssh_session_token_parser.py index fd50b7a373..bb3f8b7d7d 100644 --- a/tests/hermes_cli/test_ssh_session_token_parser.py +++ b/tests/hermes_cli/test_ssh_session_token_parser.py @@ -4,7 +4,8 @@ import os import pytest from hermes_constants import set_hermes_home_override, reset_hermes_home_override -from hermes_cli.main import _read_ssh_session_token_file, cmd_dashboard +from hermes_cli.main import cmd_dashboard +from hermes_cli.main_dashboard import _read_ssh_session_token_file from hermes_cli.subcommands.dashboard import build_dashboard_parser diff --git a/tests/hermes_cli/test_stale_pid_guard.py b/tests/hermes_cli/test_stale_pid_guard.py index 3323eedb26..b3d32deb5b 100644 --- a/tests/hermes_cli/test_stale_pid_guard.py +++ b/tests/hermes_cli/test_stale_pid_guard.py @@ -176,13 +176,13 @@ class TestStopProcessTrees: class TestKillStaleDashboardProcesses: """dashboard_procs win32 kill branch guard behaviour.""" - def _fake_m(self, pids=(12345,)): - m = mock.Mock() - m._find_stale_dashboard_pids.return_value = list(pids) - return m + def _patch_find(self, pids=(12345,)): + from hermes_cli import main_dashboard + + return mock.patch.object(main_dashboard, "_find_stale_dashboard_pids", return_value=list(pids)) def test_foreign_pid_reported_not_killed(self): - with mock.patch.object(dashboard_procs, "_m", return_value=self._fake_m()), mock.patch.object( + with self._patch_find(), mock.patch.object( dashboard_procs.sys, "platform", "win32" ), mock.patch( "gateway.status.get_process_start_time", return_value=123 @@ -197,7 +197,7 @@ class TestKillStaleDashboardProcesses: run.assert_not_called() def test_hermes_pid_killed(self): - with mock.patch.object(dashboard_procs, "_m", return_value=self._fake_m()), mock.patch.object( + with self._patch_find(), mock.patch.object( dashboard_procs.sys, "platform", "win32" ), mock.patch( "gateway.status.get_process_start_time", return_value=123 diff --git a/tests/hermes_cli/test_state_db_guard.py b/tests/hermes_cli/test_state_db_guard.py index e373181123..0602b96d7a 100644 --- a/tests/hermes_cli/test_state_db_guard.py +++ b/tests/hermes_cli/test_state_db_guard.py @@ -112,7 +112,7 @@ class TestPreUpdateBackupIntegrityGuard: def test_healthy_db_stays_quiet(self, hermes_home, capsys): from argparse import Namespace - from hermes_cli.main import _run_pre_update_backup + from hermes_cli.update_cmd import _run_pre_update_backup snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=False)) out = capsys.readouterr().out @@ -126,7 +126,7 @@ class TestPreUpdateBackupIntegrityGuard: from argparse import Namespace import hermes_cli.backup as backup_mod - from hermes_cli.main import _run_pre_update_backup + from hermes_cli.update_cmd import _run_pre_update_backup real_create = backup_mod.create_quick_snapshot diff --git a/tests/hermes_cli/test_terminal_menu_fallbacks.py b/tests/hermes_cli/test_terminal_menu_fallbacks.py index 9e69c5190c..f429684742 100644 --- a/tests/hermes_cli/test_terminal_menu_fallbacks.py +++ b/tests/hermes_cli/test_terminal_menu_fallbacks.py @@ -113,7 +113,7 @@ def test_prompt_model_selection_fallback_uses_line_editor_for_custom_model( def test_remove_custom_provider_falls_back_on_menu_runtime_error(tmp_path, monkeypatch): - from hermes_cli.main import _remove_custom_provider + from hermes_cli.main_provider_setup import _remove_custom_provider monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.setattr("hermes_cli.curses_ui.curses_radiolist", _raise_menu) diff --git a/tests/hermes_cli/test_tui_bundled.py b/tests/hermes_cli/test_tui_bundled.py index ca931547c7..c54d54cfc1 100644 --- a/tests/hermes_cli/test_tui_bundled.py +++ b/tests/hermes_cli/test_tui_bundled.py @@ -7,7 +7,7 @@ def test_tui_finds_bundled_entry_js(tmp_path): entry = tui_dist / "entry.js" entry.write_text("// bundled TUI", encoding="utf-8") - from hermes_cli.main import _find_bundled_tui + from hermes_cli.main_tui_launch import _find_bundled_tui result = _find_bundled_tui(hermes_cli_dir=tmp_path / "hermes_cli") assert result is not None assert result.name == "entry.js" diff --git a/tests/hermes_cli/test_tui_heap_sizing.py b/tests/hermes_cli/test_tui_heap_sizing.py index dd8f6c287a..b761b0016f 100644 --- a/tests/hermes_cli/test_tui_heap_sizing.py +++ b/tests/hermes_cli/test_tui_heap_sizing.py @@ -12,6 +12,7 @@ import io from unittest import mock import hermes_cli.main as m +from hermes_cli import main_tui_launch V2 = "/sys/fs/cgroup/memory.max" V1 = "/sys/fs/cgroup/memory/memory.limit_in_bytes" @@ -35,7 +36,7 @@ def _fake_open(files: dict): def _read(files: dict): with mock.patch.object(builtins, "open", _fake_open(files)): - return m._read_cgroup_memory_limit() + return main_tui_launch._read_cgroup_memory_limit() class TestReadCgroupMemoryLimit: @@ -45,8 +46,8 @@ class TestReadCgroupMemoryLimit: class TestResolveTuiHeapMb: def _resolve(self, limit_bytes): - with mock.patch.object(m, "_read_cgroup_memory_limit", return_value=limit_bytes): - return m._resolve_tui_heap_mb() + with mock.patch.object(main_tui_launch, "_read_cgroup_memory_limit", return_value=limit_bytes): + return main_tui_launch._resolve_tui_heap_mb() def test_unconstrained_uses_default(self): assert self._resolve(None) == 8192 @@ -57,10 +58,10 @@ class TestNodeOptionsTokenMerge: already supplied one, and must preserve unrelated NODE_OPTIONS flags.""" def _merge(self, node_options, limit_bytes): - with mock.patch.object(m, "_read_cgroup_memory_limit", return_value=limit_bytes): + with mock.patch.object(main_tui_launch, "_read_cgroup_memory_limit", return_value=limit_bytes): tokens = node_options.split() if not any(t.startswith("--max-old-space-size=") for t in tokens): - tokens.append(f"--max-old-space-size={m._resolve_tui_heap_mb()}") + tokens.append(f"--max-old-space-size={main_tui_launch._resolve_tui_heap_mb()}") return " ".join(tokens) def test_unconstrained_empty(self): diff --git a/tests/hermes_cli/test_tui_npm_install.py b/tests/hermes_cli/test_tui_npm_install.py index 49a3346722..40003de583 100644 --- a/tests/hermes_cli/test_tui_npm_install.py +++ b/tests/hermes_cli/test_tui_npm_install.py @@ -6,6 +6,7 @@ import types from pathlib import Path import pytest +from hermes_cli import main_tui_launch @pytest.fixture @@ -69,7 +70,7 @@ def test_make_tui_argv_uses_bundled_tui_when_workspace_missing( '"node_modules/foo":{"version":"1.0.0","dev":true,"resolved":"https://x/foo.tgz"}' '}}' ) - assert main_mod._tui_need_npm_install(tmp_path) is False + assert main_tui_launch._tui_need_npm_install(tmp_path) is False def test_install_when_version_differs_even_with_peer_drop(tmp_path: Path, main_mod) -> None: @@ -81,7 +82,7 @@ def test_install_when_version_differs_even_with_peer_drop(tmp_path: Path, main_m (tmp_path / "node_modules" / ".package-lock.json").write_text( '{"packages":{"node_modules/foo":{"version":"1.0.0","dev":true}}}' ) - assert main_mod._tui_need_npm_install(tmp_path) is True + assert main_tui_launch._tui_need_npm_install(tmp_path) is True def test_no_install_when_lock_older_than_marker(tmp_path: Path, main_mod) -> None: @@ -90,18 +91,18 @@ def test_no_install_when_lock_older_than_marker(tmp_path: Path, main_mod) -> Non (tmp_path / "node_modules" / ".package-lock.json").write_text("{}") os.utime(tmp_path / "package-lock.json", (100, 100)) os.utime(tmp_path / "node_modules" / ".package-lock.json", (200, 200)) - assert main_mod._tui_need_npm_install(tmp_path) is False + assert main_tui_launch._tui_need_npm_install(tmp_path) is False def test_need_install_when_marker_missing(tmp_path: Path, main_mod) -> None: _touch_ink(tmp_path) (tmp_path / "package-lock.json").write_text("{}") - assert main_mod._tui_need_npm_install(tmp_path) is True + assert main_tui_launch._tui_need_npm_install(tmp_path) is True def test_no_install_without_lockfile_when_ink_present(tmp_path: Path, main_mod) -> None: _touch_ink(tmp_path) - assert main_mod._tui_need_npm_install(tmp_path) is False + assert main_tui_launch._tui_need_npm_install(tmp_path) is False # ── workspace-scoped comparison (#66978) ──────────────────────────── @@ -147,7 +148,7 @@ def test_no_install_when_only_other_workspace_deps_missing(tmp_path: Path, main_ '"node_modules/foo":{"version":"1.0.0"}' "}}", ) - assert main_mod._tui_need_npm_install(tui_dir) is False + assert main_tui_launch._tui_need_npm_install(tui_dir) is False def test_need_install_when_ui_tui_dep_missing_in_workspace_layout(tmp_path: Path, main_mod) -> None: @@ -164,7 +165,7 @@ def test_need_install_when_ui_tui_dep_missing_in_workspace_layout(tmp_path: Path '"node_modules/foo":{"version":"1.0.0"}' "}}", ) - assert main_mod._tui_need_npm_install(tui_dir) is True + assert main_tui_launch._tui_need_npm_install(tui_dir) is True def test_need_install_when_linked_workspace_dep_missing(tmp_path: Path, main_mod) -> None: @@ -184,7 +185,7 @@ def test_need_install_when_linked_workspace_dep_missing(tmp_path: Path, main_mod '"ui-tui/packages/hermes-ink":{"dependencies":{"inkdep":"1.0.0"}}' "}}", ) - assert main_mod._tui_need_npm_install(tui_dir) is True + assert main_tui_launch._tui_need_npm_install(tui_dir) is True def test_need_install_when_closure_package_version_drifts(tmp_path: Path, main_mod) -> None: @@ -200,7 +201,7 @@ def test_need_install_when_closure_package_version_drifts(tmp_path: Path, main_m '"node_modules/foo":{"version":"1.0.0"}' "}}", ) - assert main_mod._tui_need_npm_install(tui_dir) is True + assert main_tui_launch._tui_need_npm_install(tui_dir) is True def test_workspace_closure_includes_dev_deps_of_scoped_workspace(main_mod) -> None: @@ -215,14 +216,14 @@ def test_workspace_closure_includes_dev_deps_of_scoped_workspace(main_mod) -> No "node_modules/esbuild": {}, "node_modules/foo-dev-only": {}, } - closure = main_mod._npm_lock_workspace_closure(packages, "ui-tui") + closure = main_tui_launch._npm_lock_workspace_closure(packages, "ui-tui") assert "node_modules/esbuild" in closure assert "node_modules/foo-dev-only" not in closure def test_workspace_closure_returns_none_when_start_absent(main_mod) -> None: """Missing workspace key → None so the caller falls back to full compare.""" - assert main_mod._npm_lock_workspace_closure({"node_modules/foo": {}}, "ui-tui") is None + assert main_tui_launch._npm_lock_workspace_closure({"node_modules/foo": {}}, "ui-tui") is None def test_workspace_closure_includes_dev_deps_of_selected_child_workspace(main_mod) -> None: @@ -239,10 +240,10 @@ def test_workspace_closure_includes_dev_deps_of_selected_child_workspace(main_mo "node_modules/child-dev-only": {}, } # Only ui-tui selected (desktop): the child's dev dep is not installed. - desktop = main_mod._npm_lock_workspace_closure(packages, {"ui-tui"}) + desktop = main_tui_launch._npm_lock_workspace_closure(packages, {"ui-tui"}) assert "node_modules/child-dev-only" not in desktop # ui-tui + child selected (Termux): the child's dev dep is in the closure. - termux = main_mod._npm_lock_workspace_closure( + termux = main_tui_launch._npm_lock_workspace_closure( packages, {"ui-tui", "ui-tui/packages/hermes-ink"} ) assert "node_modules/child-dev-only" in termux @@ -275,23 +276,23 @@ def test_termux_install_catches_missing_child_workspace_dev_dep( (child / "package.json").write_text('{"name":"@hermes/ink"}') monkeypatch.setattr(main_mod, "_is_termux_startup_environment", lambda: False) - assert main_mod._tui_need_npm_install(tui_dir) is False + assert main_tui_launch._tui_need_npm_install(tui_dir) is False monkeypatch.setattr(main_mod, "_is_termux_startup_environment", lambda: True) - assert main_mod._tui_need_npm_install(tui_dir) is True + assert main_tui_launch._tui_need_npm_install(tui_dir) is True def test_no_install_prebuilt_bundle_mode(tmp_path: Path, main_mod) -> None: """dist/entry.js present and no package-lock.json → prebuilt bundle, skip npm install.""" _touch_tui_entry(tmp_path) - assert main_mod._tui_need_npm_install(tmp_path) is False + assert main_tui_launch._tui_need_npm_install(tmp_path) is False def test_need_rebuild_when_tui_bundle_missing(tmp_path: Path, main_mod) -> None: (tmp_path / "src").mkdir() (tmp_path / "src" / "entry.tsx").write_text("console.log('src')") - assert main_mod._tui_need_rebuild(tmp_path) is True + assert main_tui_launch._tui_need_rebuild(tmp_path) is True def test_no_rebuild_when_tui_bundle_newer_than_inputs(tmp_path: Path, main_mod) -> None: @@ -302,7 +303,7 @@ def test_no_rebuild_when_tui_bundle_newer_than_inputs(tmp_path: Path, main_mod) os.utime(src / "entry.tsx", (100, 100)) os.utime(tmp_path / "dist" / "entry.js", (200, 200)) - assert main_mod._tui_need_rebuild(tmp_path) is False + assert main_tui_launch._tui_need_rebuild(tmp_path) is False def test_rebuild_when_tui_source_newer_than_bundle(tmp_path: Path, main_mod) -> None: @@ -313,7 +314,7 @@ def test_rebuild_when_tui_source_newer_than_bundle(tmp_path: Path, main_mod) -> os.utime(tmp_path / "dist" / "entry.js", (100, 100)) os.utime(src / "entry.tsx", (200, 200)) - assert main_mod._tui_need_rebuild(tmp_path) is True + assert main_tui_launch._tui_need_rebuild(tmp_path) is True def test_make_tui_argv_skips_build_only_on_termux_when_fresh( @@ -321,8 +322,8 @@ def test_make_tui_argv_skips_build_only_on_termux_when_fresh( ) -> None: _touch_tui_entry(tmp_path) monkeypatch.setenv("TERMUX_VERSION", "1") - monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: False) - monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: False) + monkeypatch.setattr(main_tui_launch, "_tui_need_npm_install", lambda _root: False) + monkeypatch.setattr(main_tui_launch, "_tui_need_rebuild", lambda _root: False) monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}") def fail_run(*_args, **_kwargs): @@ -330,7 +331,7 @@ def test_make_tui_argv_skips_build_only_on_termux_when_fresh( monkeypatch.setattr(main_mod.subprocess, "run", fail_run) - argv, cwd = main_mod._make_tui_argv(tmp_path, tui_dev=False) + argv, cwd = main_tui_launch._make_tui_argv(tmp_path, tui_dev=False) assert argv == ["/bin/node", "--expose-gc", str(tmp_path / "dist" / "entry.js")] assert cwd == tmp_path @@ -341,8 +342,8 @@ def test_make_tui_argv_skips_install_on_termux_when_bundle_fresh( ) -> None: _touch_tui_entry(tmp_path) monkeypatch.setenv("TERMUX_VERSION", "1") - monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True) - monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: False) + monkeypatch.setattr(main_tui_launch, "_tui_need_npm_install", lambda _root: True) + monkeypatch.setattr(main_tui_launch, "_tui_need_rebuild", lambda _root: False) monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}") def fail_run(*_args, **_kwargs): @@ -350,7 +351,7 @@ def test_make_tui_argv_skips_install_on_termux_when_bundle_fresh( monkeypatch.setattr(main_mod.subprocess, "run", fail_run) - argv, cwd = main_mod._make_tui_argv(tmp_path, tui_dev=False) + argv, cwd = main_tui_launch._make_tui_argv(tmp_path, tui_dev=False) assert argv == ["/bin/node", "--expose-gc", str(tmp_path / "dist" / "entry.js")] assert cwd == tmp_path @@ -368,8 +369,8 @@ def test_make_tui_argv_scopes_npm_install_on_termux_workspace( (tmp_path / "package-lock.json").write_text("{}") monkeypatch.setenv("TERMUX_VERSION", "1") - monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True) - monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: True) + monkeypatch.setattr(main_tui_launch, "_tui_need_npm_install", lambda _root: True) + monkeypatch.setattr(main_tui_launch, "_tui_need_rebuild", lambda _root: True) monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}") calls = [] @@ -379,7 +380,7 @@ def test_make_tui_argv_scopes_npm_install_on_termux_workspace( monkeypatch.setattr(main_mod.subprocess, "run", fake_run) - main_mod._make_tui_argv(tui_dir, tui_dev=False) + main_tui_launch._make_tui_argv(tui_dir, tui_dev=False) install_cmd = calls[0][0][0] assert install_cmd[:7] == [ @@ -406,7 +407,7 @@ def test_make_tui_argv_keeps_desktop_workspace_install_behaviour( monkeypatch.delenv("TERMUX_VERSION", raising=False) monkeypatch.setenv("PREFIX", "/usr") - monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True) + monkeypatch.setattr(main_tui_launch, "_tui_need_npm_install", lambda _root: True) monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}") calls = [] @@ -416,7 +417,7 @@ def test_make_tui_argv_keeps_desktop_workspace_install_behaviour( monkeypatch.setattr(main_mod.subprocess, "run", fake_run) - main_mod._make_tui_argv(tui_dir, tui_dev=False) + main_tui_launch._make_tui_argv(tui_dir, tui_dev=False) assert calls[0][0][0] == [ "/bin/npm", @@ -450,7 +451,7 @@ def test_make_tui_argv_npm_install_forces_include_dev( monkeypatch.delenv("TERMUX_VERSION", raising=False) monkeypatch.setenv("PREFIX", "/usr") monkeypatch.setenv("NODE_ENV", "production") - monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True) + monkeypatch.setattr(main_tui_launch, "_tui_need_npm_install", lambda _root: True) monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}") calls = [] @@ -460,7 +461,7 @@ def test_make_tui_argv_npm_install_forces_include_dev( monkeypatch.setattr(main_mod.subprocess, "run", fake_run) - main_mod._make_tui_argv(tui_dir, tui_dev=False) + main_tui_launch._make_tui_argv(tui_dir, tui_dev=False) install_cmd = calls[0][0][0] assert install_cmd[:2] == ["/bin/npm", "install"] @@ -473,8 +474,8 @@ def test_make_tui_argv_keeps_desktop_always_build_behaviour( _touch_tui_entry(tmp_path) monkeypatch.delenv("TERMUX_VERSION", raising=False) monkeypatch.setenv("PREFIX", "/usr") - monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: False) - monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: False) + monkeypatch.setattr(main_tui_launch, "_tui_need_npm_install", lambda _root: False) + monkeypatch.setattr(main_tui_launch, "_tui_need_rebuild", lambda _root: False) monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}") calls = [] @@ -484,7 +485,7 @@ def test_make_tui_argv_keeps_desktop_always_build_behaviour( monkeypatch.setattr(main_mod.subprocess, "run", fake_run) - main_mod._make_tui_argv(tmp_path, tui_dev=False) + main_tui_launch._make_tui_argv(tmp_path, tui_dev=False) assert calls assert calls[0][0][0] == ["/bin/npm", "run", "build"] @@ -500,7 +501,7 @@ def test_make_tui_argv_decodes_dev_prebuild_with_utf8_replace( tsx.parent.mkdir(parents=True) tsx.write_text("") - monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: False) + monkeypatch.setattr(main_tui_launch, "_tui_need_npm_install", lambda _root: False) monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}") calls = [] @@ -510,7 +511,7 @@ def test_make_tui_argv_decodes_dev_prebuild_with_utf8_replace( monkeypatch.setattr(main_mod.subprocess, "run", fake_run) - argv, cwd = main_mod._make_tui_argv(tmp_path, tui_dev=True) + argv, cwd = main_tui_launch._make_tui_argv(tmp_path, tui_dev=True) assert argv == [str(tsx), "src/entry.tsx"] assert cwd == tmp_path @@ -524,12 +525,12 @@ def test_make_tui_argv_exits_with_recovery_hint_when_workspace_unrecoverable( ) -> None: """Missing ui-tui + no git checkout → clean error, never touches node/npm.""" monkeypatch.delenv("HERMES_TUI_DIR", raising=False) - monkeypatch.setattr(main_mod, "_ensure_tui_node", lambda: None) + monkeypatch.setattr(main_tui_launch, "_ensure_tui_node", lambda: None) bundled_entry = tmp_path / "bundled" / "entry.js" bundled_entry.parent.mkdir(parents=True) bundled_entry.write_text("// bundled TUI") - monkeypatch.setattr(main_mod, "_find_bundled_tui", lambda: bundled_entry) + monkeypatch.setattr(main_tui_launch, "_find_bundled_tui", lambda: bundled_entry) def which(name: str) -> str | None: if name == "node": @@ -548,7 +549,7 @@ def test_make_tui_argv_exits_with_recovery_hint_when_workspace_unrecoverable( tui_dir = tmp_path / "ui-tui" assert not tui_dir.exists() - argv, cwd = main_mod._make_tui_argv(tui_dir, tui_dev=False) + argv, cwd = main_tui_launch._make_tui_argv(tui_dir, tui_dev=False) assert argv == ["/usr/bin/node", "--expose-gc", str(bundled_entry)] assert cwd == bundled_entry.parent @@ -612,7 +613,7 @@ def test_need_npm_install_false_with_reduced_npm11_hidden_lockfile( # Must be False: real skew keys (resolved/integrity) match, declarative # omissions and extraneous are ignored, and the workspace link is skipped. - assert main_mod._tui_need_npm_install(ws) is False + assert main_tui_launch._tui_need_npm_install(ws) is False def test_need_npm_install_true_when_resolved_drifts(tmp_path: Path, main_mod) -> None: @@ -651,7 +652,7 @@ def test_need_npm_install_true_when_resolved_drifts(tmp_path: Path, main_mod) -> ) # resolved/integrity differ on both sides → must reinstall. - assert main_mod._tui_need_npm_install(ws) is True + assert main_tui_launch._tui_need_npm_install(ws) is True def test_need_npm_install_true_when_regular_pkg_missing(tmp_path: Path, main_mod) -> None: @@ -692,7 +693,7 @@ def test_need_npm_install_true_when_regular_pkg_missing(tmp_path: Path, main_mod ) ) - assert main_mod._tui_need_npm_install(ws) is True + assert main_tui_launch._tui_need_npm_install(ws) is True def test_no_stray_lockfiles_in_workspace_subdirs(main_mod) -> None: @@ -753,7 +754,7 @@ def test_make_tui_argv_omits_workspace_and_scrubs_esbuild_override( monkeypatch.delenv("TERMUX_VERSION", raising=False) monkeypatch.setenv("PREFIX", "/usr") monkeypatch.setenv("ESBUILD_BINARY_PATH", "/opt/esbuild-0.28.2") - monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True) + monkeypatch.setattr(main_tui_launch, "_tui_need_npm_install", lambda _root: True) monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}") calls = [] @@ -763,7 +764,7 @@ def test_make_tui_argv_omits_workspace_and_scrubs_esbuild_override( monkeypatch.setattr(main_mod.subprocess, "run", fake_run) - main_mod._make_tui_argv(tui_dir, tui_dev=False) + main_tui_launch._make_tui_argv(tui_dir, tui_dev=False) install_cmd = calls[0][0][0] # Must NOT contain --workspace when npm_cwd == tui_dir diff --git a/tests/hermes_cli/test_tui_resume_flow.py b/tests/hermes_cli/test_tui_resume_flow.py index 33ff8f8700..fe6ca2c8b4 100644 --- a/tests/hermes_cli/test_tui_resume_flow.py +++ b/tests/hermes_cli/test_tui_resume_flow.py @@ -7,6 +7,7 @@ import textwrap import types import pytest +from hermes_cli import main_tui_launch def _args(**overrides): @@ -220,9 +221,7 @@ def test_launch_tui_exports_model_provider_and_toolsets(monkeypatch, main_mod): captured = {} active_path_during_call = None - monkeypatch.setattr( - main_mod, - "_make_tui_argv", + monkeypatch.setattr(main_tui_launch, "_make_tui_argv", lambda tui_dir, tui_dev: (["node", "dist/entry.js"], Path(".")), ) @@ -264,8 +263,8 @@ def test_make_tui_argv_dev_prebuilds_hermes_ink(monkeypatch, main_mod, tmp_path) ink_dir.mkdir(parents=True) tsx.write_text("#!/usr/bin/env node\n", encoding="utf-8") - monkeypatch.setattr(main_mod, "_ensure_tui_node", lambda: None) - monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _tui_dir: False) + monkeypatch.setattr(main_tui_launch, "_ensure_tui_node", lambda: None) + monkeypatch.setattr(main_tui_launch, "_tui_need_npm_install", lambda _tui_dir: False) monkeypatch.delenv("HERMES_TUI_DIR", raising=False) monkeypatch.setattr(main_mod.shutil, "which", lambda bin_name: f"/usr/bin/{bin_name}") @@ -277,7 +276,7 @@ def test_make_tui_argv_dev_prebuilds_hermes_ink(monkeypatch, main_mod, tmp_path) monkeypatch.setattr(main_mod.subprocess, "run", fake_run) - argv, cwd = main_mod._make_tui_argv(tui_dir, tui_dev=True) + argv, cwd = main_tui_launch._make_tui_argv(tui_dir, tui_dev=True) assert argv == [str(tsx), "src/entry.tsx"] assert cwd == tui_dir diff --git a/tests/hermes_cli/test_update_autostash.py b/tests/hermes_cli/test_update_autostash.py index b86cd898da..cb9ca5cb0e 100644 --- a/tests/hermes_cli/test_update_autostash.py +++ b/tests/hermes_cli/test_update_autostash.py @@ -7,6 +7,7 @@ import pytest from hermes_cli import config as hermes_config from hermes_cli import main as hermes_main +from hermes_cli import update_cmd # --------------------------------------------------------------------------- @@ -310,7 +311,7 @@ def test_cmd_update_orphan_history_backs_up_before_reset(monkeypatch, tmp_path, assert "orphan divergence" in out assert ref_name in out # The user is told the backup is temporary and when it expires. - assert f"expires after {hermes_main._ORPHAN_RESCUE_REF_MAX_AGE_DAYS} days" in out + assert f"expires after {update_cmd._ORPHAN_RESCUE_REF_MAX_AGE_DAYS} days" in out def test_cmd_update_orphan_rescue_ref_write_failure_message_is_honest(monkeypatch, tmp_path, capsys): @@ -341,7 +342,7 @@ def test_cmd_update_orphan_rescue_refs_pruned_beyond_keep_limit(monkeypatch, tmp # All refs are recent (within the age window) so only the count cap # applies — the age-expiry path is exercised separately below. now = datetime.now(timezone.utc) - total = hermes_main._ORPHAN_RESCUE_REFS_TO_KEEP + 2 + total = update_cmd._ORPHAN_RESCUE_REFS_TO_KEEP + 2 stale_refs = [ "refs/hermes-update-backups/orphan-main-" f"{(now - timedelta(hours=total - i)).strftime('%Y%m%d-%H%M%S')}-abc" @@ -358,9 +359,9 @@ def test_cmd_update_orphan_rescue_refs_pruned_beyond_keep_limit(monkeypatch, tmp c for c in recorded if "update-ref" in " ".join(str(x) for x in c) and "-d" in c ] - assert len(delete_calls) == total - hermes_main._ORPHAN_RESCUE_REFS_TO_KEEP + assert len(delete_calls) == total - update_cmd._ORPHAN_RESCUE_REFS_TO_KEEP deleted_refs = {c[c.index("-d") + 1] for c in delete_calls} - assert deleted_refs == set(stale_refs[: total - hermes_main._ORPHAN_RESCUE_REFS_TO_KEEP]) + assert deleted_refs == set(stale_refs[: total - update_cmd._ORPHAN_RESCUE_REFS_TO_KEEP]) def test_cmd_update_orphan_rescue_refs_expired_by_age(monkeypatch, tmp_path, capsys): @@ -373,7 +374,7 @@ def test_cmd_update_orphan_rescue_refs_expired_by_age(monkeypatch, tmp_path, cap _setup_update_mocks(monkeypatch, tmp_path) now = datetime.now(timezone.utc) - old = now - timedelta(days=hermes_main._ORPHAN_RESCUE_REF_MAX_AGE_DAYS + 5) + old = now - timedelta(days=update_cmd._ORPHAN_RESCUE_REF_MAX_AGE_DAYS + 5) fresh = now - timedelta(days=1) expired_ref = ( "refs/hermes-update-backups/orphan-main-" @@ -415,7 +416,7 @@ def test_prune_orphan_rescue_refs_leaves_unparseable_names_alone(): return NS(stdout="", stderr="", returncode=0) with mock_patch.object(hermes_main.subprocess, "run", side_effect=fake_run): - hermes_main._prune_orphan_rescue_refs(["git"], ".", "main") + update_cmd._prune_orphan_rescue_refs(["git"], ".", "main") delete_calls = [c for c in calls if "update-ref" in c and "-d" in c] assert delete_calls == [] @@ -1225,7 +1226,7 @@ def test_prune_orphan_rescue_refs_with_real_git_unpins_objects(tmp_path): assert git("cat-file", "-e", snap_sha, check=False).returncode == 0 # Prune (the ref's 2020 timestamp is way past the age window) → ref gone. - hermes_main._prune_orphan_rescue_refs(["git"], tmp_path, "main") + update_cmd._prune_orphan_rescue_refs(["git"], tmp_path, "main") remaining = git("for-each-ref", "refs/hermes-update-backups/").stdout assert old_ref not in remaining diff --git a/tests/hermes_cli/test_update_concurrent_quarantine.py b/tests/hermes_cli/test_update_concurrent_quarantine.py index dca6ffae95..add237e2ba 100644 --- a/tests/hermes_cli/test_update_concurrent_quarantine.py +++ b/tests/hermes_cli/test_update_concurrent_quarantine.py @@ -18,6 +18,9 @@ from unittest.mock import MagicMock, patch import pytest from hermes_cli import main as cli_main +from hermes_cli import dashboard_procs +from hermes_cli import main_install_repair +from hermes_cli import update_cmd # Tests in this module either exercise the REAL _detect_concurrent_hermes_instances @@ -94,7 +97,7 @@ def _fake_psutil_with_parent_chain( ) -@patch.object(cli_main, "_is_windows", return_value=True) +@patch.object(main_install_repair, "_is_windows", return_value=True) def test_detect_concurrent_parents_call_robust_to_one_bad_hop(_winp, tmp_path): """The launcher shim is still excluded even when an ancestor exe is unreadable. @@ -143,13 +146,13 @@ def test_detect_concurrent_parents_call_robust_to_one_bad_hop(_winp, tmp_path): # --------------------------------------------------------------------------- -@patch.object(cli_main, "_is_windows", return_value=True) +@patch.object(main_install_repair, "_is_windows", return_value=True) def test_quarantine_succeeds_first_attempt(_winp, tmp_path): """When the rename works immediately, no warning, single rename pair returned.""" shim = tmp_path / "hermes.exe" shim.write_bytes(b"old") - pairs = cli_main._quarantine_running_hermes_exe(tmp_path) + pairs = main_install_repair._quarantine_running_hermes_exe(tmp_path) assert len(pairs) == 1 orig, quarantine = pairs[0] @@ -159,7 +162,7 @@ def test_quarantine_succeeds_first_attempt(_winp, tmp_path): assert not shim.exists() -@patch.object(cli_main, "_is_windows", return_value=True) +@patch.object(main_install_repair, "_is_windows", return_value=True) def test_quarantine_reports_a_lock_it_cannot_break(_winp, tmp_path, capsys, monkeypatch): """Every retry failed: name the likely culprits, queue nothing for reboot.""" shim = tmp_path / "hermes.exe" @@ -168,11 +171,11 @@ def test_quarantine_reports_a_lock_it_cannot_break(_winp, tmp_path, capsys, monk def always_fails(self, target): raise OSError(32, "The process cannot access the file (simulated lock)") - monkeypatch.setattr(cli_main, "_hermes_exe_shims", lambda d: [shim]) + monkeypatch.setattr(main_install_repair, "_hermes_exe_shims", lambda d: [shim]) with patch.object(Path, "rename", always_fails), patch( "time.sleep", lambda *_a, **_k: None ): - pairs = cli_main._quarantine_running_hermes_exe(tmp_path) + pairs = main_install_repair._quarantine_running_hermes_exe(tmp_path) captured = capsys.readouterr().out.lower() @@ -898,7 +901,7 @@ def test_classify_concurrent_instance_recognises_gateway_runtimes(monkeypatch): ] for argv in cases: monkeypatch.setitem(sys.modules, "psutil", _fake_psutil_classify({77: argv})) - result = cli_main._classify_concurrent_instance(77) + result = update_cmd._classify_concurrent_instance(77) assert result == "gateway", f"expected gateway for {argv!r}, got {result!r}" @@ -917,7 +920,7 @@ def test_classify_concurrent_instance_recognises_non_gateways(monkeypatch): ] for argv in cases: monkeypatch.setitem(sys.modules, "psutil", _fake_psutil_classify({77: argv})) - result = cli_main._classify_concurrent_instance(77) + result = update_cmd._classify_concurrent_instance(77) assert result == "non-gateway", ( f"expected non-gateway for {argv!r}, got {result!r}" ) @@ -927,13 +930,13 @@ def test_classify_concurrent_instance_unknown_on_psutil_error(monkeypatch): """Unreadable cmdline (process gone / AccessDenied) → ``unknown`` — treated as non-gateway by the filter, so the gate still aborts.""" monkeypatch.setitem(sys.modules, "psutil", _fake_psutil_classify({})) - assert cli_main._classify_concurrent_instance(4242) == "unknown" + assert update_cmd._classify_concurrent_instance(4242) == "unknown" def test_classify_concurrent_instance_unknown_without_psutil(monkeypatch): """Missing psutil entirely → ``unknown``, never a crash.""" monkeypatch.setitem(sys.modules, "psutil", None) - assert cli_main._classify_concurrent_instance(4242) == "unknown" + assert update_cmd._classify_concurrent_instance(4242) == "unknown" def test_filter_non_gateway_concurrent_instances_splits(monkeypatch): @@ -1016,7 +1019,7 @@ def test_update_gate_skips_abort_when_only_concurrent_is_gateway( ) as mock_backup: mock_backup.side_effect = RuntimeError("reached post-gate body") with pytest.raises(RuntimeError, match="reached post-gate body"): - cli_main._cmd_update_impl(_update_args(), gateway_mode=False) + update_cmd._cmd_update_impl(_update_args(), gateway_mode=False) mock_filter.assert_called_once() mock_backup.assert_called_once() @@ -1048,7 +1051,7 @@ def test_update_gate_still_aborts_on_non_gateway_concurrent( cli_main, "_run_pre_update_backup" ) as mock_backup: with pytest.raises(SystemExit) as excinfo: - cli_main._cmd_update_impl(_update_args(), gateway_mode=False) + update_cmd._cmd_update_impl(_update_args(), gateway_mode=False) assert excinfo.value.code == 2 mock_backup.assert_not_called() @@ -1093,7 +1096,7 @@ def test_update_impl_refuses_before_terminating_gateway_ancestor( status_mod, "terminate_pid" ) as terminate: with pytest.raises(SystemExit) as excinfo: - cli_main._cmd_update_impl(_update_args(), gateway_mode=False) + update_cmd._cmd_update_impl(_update_args(), gateway_mode=False) assert excinfo.value.code == 2 terminate.assert_not_called() diff --git a/tests/hermes_cli/test_update_fleet_check_fail_closed.py b/tests/hermes_cli/test_update_fleet_check_fail_closed.py index a0ec5a1cd1..c6d1069876 100644 --- a/tests/hermes_cli/test_update_fleet_check_fail_closed.py +++ b/tests/hermes_cli/test_update_fleet_check_fail_closed.py @@ -24,7 +24,7 @@ from __future__ import annotations import inspect import types -from hermes_cli.main import _fleet_probe_expected_runtimes +from hermes_cli.update_cmd import _fleet_probe_expected_runtimes def _plan(runtimes): diff --git a/tests/hermes_cli/test_update_fleet_probe_resume_token.py b/tests/hermes_cli/test_update_fleet_probe_resume_token.py index cc91659385..daed164680 100644 --- a/tests/hermes_cli/test_update_fleet_probe_resume_token.py +++ b/tests/hermes_cli/test_update_fleet_probe_resume_token.py @@ -33,7 +33,7 @@ from __future__ import annotations import types -from hermes_cli.main import _fleet_probe_expected_runtimes +from hermes_cli.update_cmd import _fleet_probe_expected_runtimes def _plan(runtimes): diff --git a/tests/hermes_cli/test_update_fleet_restart_timeout.py b/tests/hermes_cli/test_update_fleet_restart_timeout.py index 020f8c1ea8..948daba379 100644 --- a/tests/hermes_cli/test_update_fleet_restart_timeout.py +++ b/tests/hermes_cli/test_update_fleet_restart_timeout.py @@ -13,11 +13,7 @@ import subprocess import pytest -from hermes_cli.main import ( - _for_each_systemd_gateway_unit, - _service_unit_supports_graceful_sigusr1_restart, - _warn_incomplete_gateway_fleet_restart, -) +from hermes_cli.update_cmd import _for_each_systemd_gateway_unit, _service_unit_supports_graceful_sigusr1_restart, _warn_incomplete_gateway_fleet_restart def _list_units_stdout(names: list[str]) -> str: diff --git a/tests/hermes_cli/test_update_gateway_launcher_refresh.py b/tests/hermes_cli/test_update_gateway_launcher_refresh.py index be92a79a3d..83394e2b43 100644 --- a/tests/hermes_cli/test_update_gateway_launcher_refresh.py +++ b/tests/hermes_cli/test_update_gateway_launcher_refresh.py @@ -8,7 +8,7 @@ forever" gap: to the sibling console ``python.exe`` so respawns and regenerated launchers use the hidden-console design (#54220/#56747) and don't die with ``RuntimeError: sys.stderr is None`` (#71671). -2. ``hermes_cli.main._refresh_windows_gateway_launchers`` — ``hermes +2. ``cli_main._refresh_windows_gateway_launchers`` — ``hermes update`` regenerates the installed Scheduled Task / Startup launcher scripts instead of leaving install-time artifacts stale forever. @@ -27,6 +27,7 @@ import pytest import hermes_cli.gateway_windows as gateway_windows import hermes_cli.main as cli_main +from hermes_cli import update_cmd # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_update_gateway_restart_aborted.py b/tests/hermes_cli/test_update_gateway_restart_aborted.py index 3c15b8cdb9..46d4e9d010 100644 --- a/tests/hermes_cli/test_update_gateway_restart_aborted.py +++ b/tests/hermes_cli/test_update_gateway_restart_aborted.py @@ -15,11 +15,7 @@ from __future__ import annotations import sys import types -from hermes_cli.main import ( - _restart_phase_failure_is_incomplete, - _surviving_gateway_pids_after_failed_restart, - _warn_gateway_restart_phase_aborted, -) +from hermes_cli.update_cmd import _restart_phase_failure_is_incomplete, _surviving_gateway_pids_after_failed_restart, _warn_gateway_restart_phase_aborted class TestSurvivingGatewayProbe: diff --git a/tests/hermes_cli/test_update_handoff_backend_reap.py b/tests/hermes_cli/test_update_handoff_backend_reap.py index e25f5838e6..28eb094e0d 100644 --- a/tests/hermes_cli/test_update_handoff_backend_reap.py +++ b/tests/hermes_cli/test_update_handoff_backend_reap.py @@ -26,6 +26,7 @@ import types from unittest.mock import MagicMock, patch from hermes_cli import main as cli_main +from hermes_cli import update_cmd class _FakeNoSuchProcess(Exception): diff --git a/tests/hermes_cli/test_update_hangup_protection.py b/tests/hermes_cli/test_update_hangup_protection.py index ce53470d97..4d42445f7f 100644 --- a/tests/hermes_cli/test_update_hangup_protection.py +++ b/tests/hermes_cli/test_update_hangup_protection.py @@ -14,14 +14,8 @@ import sys import pytest -from hermes_cli.main import ( - _UpdateOutputStream, - _finalize_update_output, - _install_hangup_protection, - _log_only_write, - _print_update_completion, - _run_logged_subprocess, -) +from hermes_cli.main_dashboard import _UpdateOutputStream, _finalize_update_output, _install_hangup_protection +from hermes_cli.update_cmd import _log_only_write, _print_update_completion, _run_logged_subprocess def test_update_completion_includes_bounded_action_identity(monkeypatch, capsys): diff --git a/tests/hermes_cli/test_update_import_guard.py b/tests/hermes_cli/test_update_import_guard.py index ea4affd5ee..097b5a2e0d 100644 --- a/tests/hermes_cli/test_update_import_guard.py +++ b/tests/hermes_cli/test_update_import_guard.py @@ -52,12 +52,12 @@ def test_syntax_guard_passes_but_import_guard_catches_skew(monkeypatch, tmp_path monkeypatch.setattr( update_cmd, "_UPDATE_CRITICAL_FILES", ("consumer.py", "provider/thing.py") ) - syntax_ok, _, _ = hermes_main._validate_critical_files_syntax(tmp_path) + syntax_ok, _, _ = update_cmd._validate_critical_files_syntax(tmp_path) assert syntax_ok, "sanity: the skewed tree must parse cleanly" # The import guard catches it. monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",)) - ok, module, error = hermes_main._validate_critical_modules_import(tmp_path) + ok, module, error = update_cmd._validate_critical_modules_import(tmp_path) assert ok is False assert module == "consumer" assert error is not None and "SHARED_NAME" in error @@ -67,7 +67,7 @@ def test_import_guard_passes_on_consistent_tree(monkeypatch, tmp_path): _write_skewed_tree(tmp_path, skewed=False) monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",)) - assert hermes_main._validate_critical_modules_import(tmp_path) == (True, None, None) + assert update_cmd._validate_critical_modules_import(tmp_path) == (True, None, None) def test_import_guard_ignores_non_import_errors(monkeypatch, tmp_path): @@ -78,7 +78,7 @@ def test_import_guard_ignores_non_import_errors(monkeypatch, tmp_path): ) monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",)) - ok, _, _ = hermes_main._validate_critical_modules_import(tmp_path) + ok, _, _ = update_cmd._validate_critical_modules_import(tmp_path) assert ok is True @@ -87,7 +87,7 @@ def test_import_guard_can_report_non_import_errors(monkeypatch, tmp_path): (tmp_path / "consumer.py").write_text("raise RuntimeError('broken config')\n") monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",)) - ok, module, error = hermes_main._validate_critical_modules_import( + ok, module, error = update_cmd._validate_critical_modules_import( tmp_path, report_runtime_errors=True ) @@ -103,7 +103,7 @@ def test_import_guard_can_report_missing_third_party_dependency( (tmp_path / "consumer.py").write_text("import totally_not_installed_pkg\n") monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",)) - ok, module, error = hermes_main._validate_critical_modules_import( + ok, module, error = update_cmd._validate_critical_modules_import( tmp_path, report_runtime_errors=True ) @@ -135,7 +135,7 @@ def test_import_guard_reports_probe_termination_when_comparing_states( (tmp_path / "consumer.py").write_text("import os\nos._exit(7)\n") monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",)) - ok, module, error = hermes_main._validate_critical_modules_import( + ok, module, error = update_cmd._validate_critical_modules_import( tmp_path, report_runtime_errors=True ) @@ -149,7 +149,7 @@ def test_import_guard_reports_probe_termination_by_default(monkeypatch, tmp_path (tmp_path / "consumer.py").write_text("import os\nos._exit(9)\n") monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",)) - ok, module, error = hermes_main._validate_critical_modules_import(tmp_path) + ok, module, error = update_cmd._validate_critical_modules_import(tmp_path) assert ok is False assert module == "critical-module probe" @@ -161,7 +161,7 @@ def test_import_guard_reports_system_exit_by_default(monkeypatch, tmp_path): (tmp_path / "consumer.py").write_text("raise SystemExit('stopped')\n") monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",)) - ok, module, error = hermes_main._validate_critical_modules_import(tmp_path) + ok, module, error = update_cmd._validate_critical_modules_import(tmp_path) assert ok is False assert module == "consumer" @@ -178,7 +178,7 @@ def test_import_guard_does_not_accept_forged_static_marker(monkeypatch, tmp_path ) monkeypatch.setattr(update_cmd, "_UPDATE_CRITICAL_MODULES", ("consumer",)) - ok, module, error = hermes_main._validate_critical_modules_import(tmp_path) + ok, module, error = update_cmd._validate_critical_modules_import(tmp_path) assert ok is False assert module == "critical-module probe" @@ -197,7 +197,7 @@ def test_import_guard_rejects_malformed_health_payload(monkeypatch, tmp_path): monkeypatch.setattr(update_cmd.subprocess, "run", malformed) - ok, module, error = hermes_main._validate_critical_modules_import(tmp_path) + ok, module, error = update_cmd._validate_critical_modules_import(tmp_path) assert ok is False assert module == "critical-module probe" @@ -212,7 +212,7 @@ def test_import_guard_reports_probe_timeout(monkeypatch, tmp_path): monkeypatch.setattr(update_cmd.subprocess, "run", timeout) - ok, module, error = hermes_main._validate_critical_modules_import(tmp_path) + ok, module, error = update_cmd._validate_critical_modules_import(tmp_path) assert ok is False assert module == "critical-module probe" diff --git a/tests/hermes_cli/test_update_interrupted_recovery.py b/tests/hermes_cli/test_update_interrupted_recovery.py index aab18efacc..15c35f4a16 100644 --- a/tests/hermes_cli/test_update_interrupted_recovery.py +++ b/tests/hermes_cli/test_update_interrupted_recovery.py @@ -10,6 +10,8 @@ from __future__ import annotations from pathlib import Path import hermes_cli.main as m +from hermes_cli import main_install_repair +from hermes_cli import update_cmd def test_marker_round_trip(tmp_path, monkeypatch): @@ -69,10 +71,8 @@ def test_recovery_self_lock_does_not_clear_core_marker_via_import_probes( monkeypatch.setattr(m, "_is_windows", lambda: True) monkeypatch.setattr(m, "_venv_scripts_dir", lambda: scripts_dir) - monkeypatch.setattr(m, "_hermes_exe_shims", lambda d: [shim]) - monkeypatch.setattr( - m, - "_default_venv_install_target", + monkeypatch.setattr(main_install_repair, "_hermes_exe_shims", lambda d: [shim]) + monkeypatch.setattr(main_install_repair, "_default_venv_install_target", lambda: (["uv", "pip"], {"VIRTUAL_ENV": str(tmp_path / "venv")}), ) monkeypatch.setattr( diff --git a/tests/hermes_cli/test_update_orphan_backend_reap.py b/tests/hermes_cli/test_update_orphan_backend_reap.py index fa1a3da088..c121d0fb0d 100644 --- a/tests/hermes_cli/test_update_orphan_backend_reap.py +++ b/tests/hermes_cli/test_update_orphan_backend_reap.py @@ -24,6 +24,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch from hermes_cli import main as cli_main +from hermes_cli import update_cmd class _FakeNoSuchProcess(Exception): @@ -278,7 +279,7 @@ def _run_guard(detect_side_effect, orphan_return): "time.sleep" ): try: - cli_main._cmd_update_impl(_update_args(), gateway_mode=False) + update_cmd._cmd_update_impl(_update_args(), gateway_mode=False) except _PastGuard: return "past_guard", killed except SystemExit as exc: diff --git a/tests/hermes_cli/test_update_post_pull_syntax_guard.py b/tests/hermes_cli/test_update_post_pull_syntax_guard.py index 94ec64dae1..a430abc915 100644 --- a/tests/hermes_cli/test_update_post_pull_syntax_guard.py +++ b/tests/hermes_cli/test_update_post_pull_syntax_guard.py @@ -18,6 +18,7 @@ from pathlib import Path from types import SimpleNamespace from hermes_cli import main as hermes_main +from hermes_cli import update_cmd # --------------------------------------------------------------------------- @@ -31,7 +32,7 @@ def test_capture_head_sha_returns_stripped_sha(monkeypatch, tmp_path): monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) - assert hermes_main._capture_head_sha(["git"], tmp_path) == "deadbeefcafe" + assert update_cmd._capture_head_sha(["git"], tmp_path) == "deadbeefcafe" # --------------------------------------------------------------------------- @@ -54,7 +55,7 @@ def _populate_critical_tree(root: Path, *, broken_file: str | None = None) -> No ">>>>>>> 0b6d673e7\n" "}\n" ) - for relpath in hermes_main._UPDATE_CRITICAL_FILES: + for relpath in update_cmd._UPDATE_CRITICAL_FILES: path = root / relpath path.parent.mkdir(parents=True, exist_ok=True) if relpath == broken_file: @@ -69,14 +70,14 @@ def test_validate_critical_files_syntax_tolerates_missing_files(tmp_path): """A refactor may legitimately remove one of the critical files — the guard should skip missing files, not falsely flag the install as broken.""" # Populate everything except hermes_constants.py - for relpath in hermes_main._UPDATE_CRITICAL_FILES: + for relpath in update_cmd._UPDATE_CRITICAL_FILES: if relpath == "hermes_constants.py": continue path = tmp_path / relpath path.parent.mkdir(parents=True, exist_ok=True) path.write_text("# stub\n") - ok, failing_path, error = hermes_main._validate_critical_files_syntax(tmp_path) + ok, failing_path, error = update_cmd._validate_critical_files_syntax(tmp_path) assert ok is True assert failing_path is None diff --git a/tests/hermes_cli/test_update_self_lock.py b/tests/hermes_cli/test_update_self_lock.py index de8dfc670a..95ab333b4c 100644 --- a/tests/hermes_cli/test_update_self_lock.py +++ b/tests/hermes_cli/test_update_self_lock.py @@ -319,7 +319,7 @@ class TestUpdateEntrypointImportHygiene: from unittest.mock import patch sys.argv = ["hermes", "update", "--check"] import hermes_cli.main as m - with patch("hermes_cli.main._cmd_update_check", lambda *a, **k: 0): + with patch("hermes_cli.update_cmd._cmd_update_check", lambda *a, **k: 0): try: m.main() except SystemExit: diff --git a/tests/hermes_cli/test_update_serve_generation_recovery.py b/tests/hermes_cli/test_update_serve_generation_recovery.py index 609e1ec326..36c6884bbd 100644 --- a/tests/hermes_cli/test_update_serve_generation_recovery.py +++ b/tests/hermes_cli/test_update_serve_generation_recovery.py @@ -940,9 +940,16 @@ def test_recovery_module_reports_serve_units_in_a_real_process(): # --------------------------------------------------------------------------- +def _stub_dashboard_helpers(monkeypatch, **helpers): + """Stub the ``hermes_cli.main_dashboard`` helpers the dashboard-cleanup path reads at call time.""" + from hermes_cli import main_dashboard + + for name, value in helpers.items(): + monkeypatch.setattr(main_dashboard, name, value) + + def _dashboard_main_stub(scan_calls, *, restart_result=True): - """A ``_m()`` stand-in for the dashboard-cleanup path.""" - return SimpleNamespace( + return dict( _DASHBOARD_SYSTEMD_UNIT="hermes-dashboard.service", _restart_managed_dashboard_service=lambda reason, *a, **k: restart_result, _find_stale_dashboard_pids=lambda **kwargs: scan_calls.append(kwargs) or [], @@ -960,9 +967,7 @@ def test_managed_dashboard_restart_still_scans_for_serve_backends(monkeypatch): from hermes_cli import dashboard_procs scan_calls: list[dict] = [] - monkeypatch.setattr( - dashboard_procs, "_m", lambda: _dashboard_main_stub(scan_calls) - ) + _stub_dashboard_helpers(monkeypatch, **_dashboard_main_stub(scan_calls)) monkeypatch.setattr(dashboard_procs, "_lock_owned_serve_pids", lambda: set()) dashboard_procs._kill_stale_dashboard_processes(restart_managed=True) @@ -975,7 +980,8 @@ def test_restarted_dashboard_unit_is_not_killed_by_the_continued_scan(monkeypatc from hermes_cli import dashboard_procs killed: list[int] = [] - main = SimpleNamespace( + _stub_dashboard_helpers( + monkeypatch, _DASHBOARD_SYSTEMD_UNIT="hermes-dashboard.service", _restart_managed_dashboard_service=lambda reason, *a, **k: True, _find_stale_dashboard_pids=lambda **kwargs: [4242], @@ -983,7 +989,6 @@ def test_restarted_dashboard_unit_is_not_killed_by_the_continued_scan(monkeypatc _get_systemd_service_for_pid=lambda pid: "hermes-dashboard.service", _dashboard_cmdline_for_pid=lambda pid: None, ) - monkeypatch.setattr(dashboard_procs, "_m", lambda: main) monkeypatch.setattr(dashboard_procs, "_lock_owned_serve_pids", lambda: set()) monkeypatch.setattr(dashboard_procs.sys, "platform", "linux") monkeypatch.setattr( @@ -1002,7 +1007,8 @@ def test_serve_backend_survives_selection_when_the_dashboard_unit_restarts(monke signalled: list[int] = [] restarted: list[str] = [] - main = SimpleNamespace( + _stub_dashboard_helpers( + monkeypatch, _DASHBOARD_SYSTEMD_UNIT="hermes-dashboard.service", _restart_managed_dashboard_service=lambda reason, *a, **k: True, _find_stale_dashboard_pids=lambda **kwargs: [7001], @@ -1012,7 +1018,6 @@ def test_serve_backend_survives_selection_when_the_dashboard_unit_restarts(monke _try_restart_systemd_service=lambda svc, cg: restarted.append(svc) or True, _respawn_dashboard_processes=lambda cmds: [], ) - monkeypatch.setattr(dashboard_procs, "_m", lambda: main) monkeypatch.setattr(dashboard_procs, "_lock_owned_serve_pids", lambda: set()) monkeypatch.setattr(dashboard_procs.sys, "platform", "linux") diff --git a/tests/hermes_cli/test_update_shim_fail_closed.py b/tests/hermes_cli/test_update_shim_fail_closed.py index 7b04dbe189..5f376820d5 100644 --- a/tests/hermes_cli/test_update_shim_fail_closed.py +++ b/tests/hermes_cli/test_update_shim_fail_closed.py @@ -23,7 +23,7 @@ from unittest import mock import pytest -import hermes_cli.main as cli_main +from hermes_cli import main_install_repair import hermes_cli._install_repair as ir import hermes_cli.update_cmd as update_cmd @@ -40,7 +40,7 @@ def _make_shims(scripts_dir: Path, names=("hermes", "hermes-gateway")) -> list[P @pytest.fixture() def windows(monkeypatch): - monkeypatch.setattr(cli_main, "_is_windows", lambda: True) + monkeypatch.setattr(main_install_repair, "_is_windows", lambda: True) monkeypatch.setattr(ir, "_is_windows", lambda: True) @@ -60,16 +60,16 @@ def test_strict_quarantine_refuses_before_install(windows, tmp_path, monkeypatch return real_rename(self, target) monkeypatch.setattr(Path, "rename", deny_hermes) - monkeypatch.setattr(cli_main, "_hermes_exe_shims", lambda d: shims) + monkeypatch.setattr(main_install_repair, "_hermes_exe_shims", lambda d: shims) install_ran = [] monkeypatch.setattr( - cli_main, "_run_install_with_heartbeat", + main_install_repair, "_run_install_with_heartbeat", lambda cmd, env=None: install_ran.append(cmd), ) - with pytest.raises(cli_main.ShimQuarantineError) as exc_info: - cli_main._run_quarantined_install( + with pytest.raises(main_install_repair.ShimQuarantineError) as exc_info: + main_install_repair._run_quarantined_install( ["uv", "pip", "install", "-e", "."], scripts_dir=scripts, strict_quarantine=True, @@ -90,31 +90,31 @@ def test_non_strict_keeps_warn_and_try(windows, tmp_path, monkeypatch): Path, "rename", mock.Mock(side_effect=PermissionError(13, "held open")), ) - monkeypatch.setattr(cli_main, "_hermes_exe_shims", lambda d: shims) + monkeypatch.setattr(main_install_repair, "_hermes_exe_shims", lambda d: shims) install_ran = [] monkeypatch.setattr( - cli_main, "_run_install_with_heartbeat", + main_install_repair, "_run_install_with_heartbeat", lambda cmd, env=None: install_ran.append(cmd), ) # Default (non-strict): installer still runs — old behavior for repair # paths whose venv is already mutated. - cli_main._run_quarantined_install(["fake"], scripts_dir=scripts) + main_install_repair._run_quarantined_install(["fake"], scripts_dir=scripts) assert install_ran == [["fake"]] def test_strict_all_renames_ok_runs_install(windows, tmp_path, monkeypatch): scripts = tmp_path / "venv" / "Scripts" shims = _make_shims(scripts) - monkeypatch.setattr(cli_main, "_hermes_exe_shims", lambda d: shims) + monkeypatch.setattr(main_install_repair, "_hermes_exe_shims", lambda d: shims) install_ran = [] monkeypatch.setattr( - cli_main, "_run_install_with_heartbeat", + main_install_repair, "_run_install_with_heartbeat", lambda cmd, env=None: install_ran.append(cmd), ) - cli_main._run_quarantined_install( + main_install_repair._run_quarantined_install( ["fake"], scripts_dir=scripts, strict_quarantine=True ) assert install_ran == [["fake"]] @@ -128,17 +128,17 @@ def test_update_sync_installs_are_strict(windows, tmp_path, monkeypatch): def spy(cmd, *, env=None, scripts_dir=None, strict_quarantine=False): seen["strict"] = strict_quarantine - monkeypatch.setattr(cli_main, "_run_quarantined_install", spy) - monkeypatch.setattr(cli_main, "_venv_scripts_dir", lambda: tmp_path) + monkeypatch.setattr(main_install_repair, "_run_quarantined_install", spy) + monkeypatch.setattr(main_install_repair, "_venv_scripts_dir", lambda: tmp_path) monkeypatch.setattr( - cli_main, "_verify_console_scripts_installed", + main_install_repair, "_verify_console_scripts_installed", lambda prefix, env=None: None, ) monkeypatch.setattr( - cli_main, "_verify_core_dependencies_installed", + main_install_repair, "_verify_core_dependencies_installed", lambda prefix, env=None, group="all": None, ) - cli_main._install_python_dependencies_with_optional_fallback(["uv", "pip"]) + main_install_repair._install_python_dependencies_with_optional_fallback(["uv", "pip"]) assert seen["strict"] is True @@ -191,7 +191,7 @@ def test_refusal_writes_marker_and_exits_2(monkeypatch, capsys): monkeypatch.setattr( update_cmd, "_write_update_incomplete_marker", lambda: wrote.append(1) ) - exc = cli_main.ShimQuarantineError(["hermes.exe"]) + exc = main_install_repair.ShimQuarantineError(["hermes.exe"]) with pytest.raises(SystemExit) as exit_info: update_cmd._refuse_update_for_contended_shims(exc) assert exit_info.value.code == 2 @@ -202,9 +202,9 @@ def test_refusal_writes_marker_and_exits_2(monkeypatch, capsys): def test_shim_error_type_resolves_real_class(): - assert update_cmd._shim_quarantine_error_type() is cli_main.ShimQuarantineError + assert update_cmd._shim_quarantine_error_type() is main_install_repair.ShimQuarantineError def test_shim_error_is_not_a_zip_fallback_trigger(): - exc = cli_main.ShimQuarantineError(["hermes.exe"]) + exc = main_install_repair.ShimQuarantineError(["hermes.exe"]) assert update_cmd._should_zip_fallback_on_update_error(exc) is False diff --git a/tests/hermes_cli/test_update_shim_self_lock.py b/tests/hermes_cli/test_update_shim_self_lock.py index 388a74dcd0..bd47d4a2a4 100644 --- a/tests/hermes_cli/test_update_shim_self_lock.py +++ b/tests/hermes_cli/test_update_shim_self_lock.py @@ -23,6 +23,8 @@ from pathlib import Path import pytest from hermes_cli import main as cli_main +from hermes_cli import update_cmd +from hermes_cli import main_install_repair SHIM_NAMES = ["hermes.exe", "hermes-agent.exe", "hermes-acp.exe", "hermes-gateway.exe"] @@ -33,8 +35,11 @@ def venv(tmp_path, monkeypatch): scripts = tmp_path / "venv" / "Scripts" scripts.mkdir(parents=True) (scripts / "python.exe").write_bytes(b"") - monkeypatch.setattr(cli_main, "_is_windows", lambda: True) - monkeypatch.setattr(cli_main, "_venv_scripts_dir", lambda: scripts) + # update_cmd reads these off hermes_cli.main (frozen ``_m()`` surface); the + # install-repair helpers read their own module globals — patch both. + for target in (cli_main, main_install_repair): + monkeypatch.setattr(target, "_is_windows", lambda: True) + monkeypatch.setattr(target, "_venv_scripts_dir", lambda: scripts) monkeypatch.setattr(sys, "argv", ["hermes", "update"]) monkeypatch.delenv(cli_main._UPDATE_REEXEC_ENV, raising=False) _fake_psutil(monkeypatch, []) @@ -80,13 +85,13 @@ def _capture_popen(monkeypatch, raises: Exception | None = None): @pytest.mark.parametrize("shim_name", SHIM_NAMES) def test_detects_shim_as_argv0(venv, monkeypatch, shim_name): monkeypatch.setattr(sys, "argv", [str(venv / shim_name), "update"]) - assert cli_main._windows_shim_in_process_chain() == venv / shim_name + assert main_install_repair._windows_shim_in_process_chain() == venv / shim_name def test_detects_shim_from_zipapp_main_py(venv, monkeypatch): """runpy/zipapp launches put ``\\__main__.py`` in argv[0].""" monkeypatch.setattr(sys, "argv", [str(venv / "hermes.exe" / "__main__.py")]) - assert cli_main._windows_shim_in_process_chain() == venv / "hermes.exe" + assert main_install_repair._windows_shim_in_process_chain() == venv / "hermes.exe" def test_detects_shim_from_main_module_spec_origin(venv, monkeypatch): @@ -95,13 +100,13 @@ def test_detects_shim_from_main_module_spec_origin(venv, monkeypatch): __spec__=types.SimpleNamespace(origin=str(venv / "hermes.exe")), ) monkeypatch.setitem(sys.modules, "__main__", fake_main) - assert cli_main._windows_shim_in_process_chain() == venv / "hermes.exe" + assert main_install_repair._windows_shim_in_process_chain() == venv / "hermes.exe" def test_detects_shim_in_ancestor_chain(venv, monkeypatch): """The launcher is usually a separate parent process, not argv[0].""" _fake_psutil(monkeypatch, [str(venv / "hermes.exe")]) - assert cli_main._windows_shim_in_process_chain() == venv / "hermes.exe" + assert main_install_repair._windows_shim_in_process_chain() == venv / "hermes.exe" def test_ignores_hermes_exe_outside_the_project_venv(venv, monkeypatch, tmp_path): @@ -110,19 +115,19 @@ def test_ignores_hermes_exe_outside_the_project_venv(venv, monkeypatch, tmp_path other.mkdir(parents=True) monkeypatch.setattr(sys, "argv", [str(other / "hermes.exe"), "update"]) _fake_psutil(monkeypatch, [str(other / "hermes.exe")]) - assert cli_main._windows_shim_in_process_chain() is None + assert main_install_repair._windows_shim_in_process_chain() is None def test_no_shim_off_windows(venv, monkeypatch): - monkeypatch.setattr(cli_main, "_is_windows", lambda: False) + monkeypatch.setattr(main_install_repair, "_is_windows", lambda: False) monkeypatch.setattr(sys, "argv", [str(venv / "hermes.exe"), "update"]) - assert cli_main._windows_shim_in_process_chain() is None + assert main_install_repair._windows_shim_in_process_chain() is None def test_no_shim_without_a_venv(venv, monkeypatch): - monkeypatch.setattr(cli_main, "_venv_scripts_dir", lambda: None) + monkeypatch.setattr(main_install_repair, "_venv_scripts_dir", lambda: None) monkeypatch.setattr(sys, "argv", [str(venv / "hermes.exe"), "update"]) - assert cli_main._windows_shim_in_process_chain() is None + assert main_install_repair._windows_shim_in_process_chain() is None # --------------------------------------------------------------------------- @@ -200,7 +205,7 @@ def test_up_to_date_run_never_hands_off(venv, monkeypatch, capsys): """ monkeypatch.setattr(sys, "argv", [str(venv / "hermes.exe"), "update", "--check"]) calls = _capture_popen(monkeypatch) - monkeypatch.setattr(cli_main, "_cmd_update_check", lambda **kwargs: None) + monkeypatch.setattr(update_cmd, "_cmd_update_check", lambda **kwargs: None) cli_main.cmd_update(types.SimpleNamespace(check=True, branch=None)) @@ -268,7 +273,7 @@ def test_pending_rename_filter_drops_only_our_shim_pairs(): r"\??\C:\hermes\venv\Scripts\hermes.exe", r"!\??\C:\hermes\venv\Scripts\hermes.exe.old.1755624735000", ] - kept, removed = cli_main._filter_pending_shim_renames(entries, shims) + kept, removed = main_install_repair._filter_pending_shim_renames(entries, shims) assert removed == 1 assert kept == entries[:2] @@ -278,7 +283,7 @@ def test_pending_rename_filter_keeps_a_shim_pair_with_a_foreign_target(): entries = [ r"\??\C:\hermes\venv\Scripts\hermes.exe", r"!\??\C:\somewhere\else.exe", ] - kept, removed = cli_main._filter_pending_shim_renames(entries, shims) + kept, removed = main_install_repair._filter_pending_shim_renames(entries, shims) assert removed == 0 assert kept == entries @@ -286,7 +291,7 @@ def test_pending_rename_filter_keeps_a_shim_pair_with_a_foreign_target(): def test_pending_rename_filter_preserves_a_trailing_delete_entry(): """A bare source with an empty target is a scheduled delete, not a pair.""" entries = [r"\??\C:\other\thing.dll", "", r"\??\C:\other\orphan.dll"] - kept, removed = cli_main._filter_pending_shim_renames(entries, []) + kept, removed = main_install_repair._filter_pending_shim_renames(entries, []) assert removed == 0 assert kept == entries @@ -302,5 +307,5 @@ def test_venv_scripts_dir_finds_both_layouts(tmp_path, monkeypatch, venv_name): scripts = tmp_path / venv_name / "Scripts" scripts.mkdir(parents=True) monkeypatch.setattr(cli_main, "PROJECT_ROOT", tmp_path) - monkeypatch.setattr(cli_main, "_is_windows", lambda: True) - assert cli_main._venv_scripts_dir() == scripts + monkeypatch.setattr(main_install_repair, "_is_windows", lambda: True) + assert main_install_repair._venv_scripts_dir() == scripts diff --git a/tests/hermes_cli/test_update_stale_dashboard.py b/tests/hermes_cli/test_update_stale_dashboard.py index 9177b53745..a4da3d0ea1 100644 --- a/tests/hermes_cli/test_update_stale_dashboard.py +++ b/tests/hermes_cli/test_update_stale_dashboard.py @@ -22,32 +22,25 @@ from unittest.mock import patch, MagicMock import pytest -from hermes_cli.main import ( - _finish_dashboard_update_cleanup, - _find_stale_dashboard_pids, - _kill_stale_dashboard_processes, - _restart_managed_dashboard_service, - _warn_stale_dashboard_processes, # back-compat alias -) +from hermes_cli.main_dashboard import _find_stale_dashboard_pids +from hermes_cli.dashboard_procs import _kill_stale_dashboard_processes +from hermes_cli import dashboard_procs +from hermes_cli import main_dashboard +from hermes_cli import update_cmd +from hermes_cli.update_cmd import _finish_dashboard_update_cleanup +from hermes_cli.main_dashboard import _restart_managed_dashboard_service +from hermes_cli.dashboard_procs import _kill_stale_dashboard_processes as _warn_stale_dashboard_processes @pytest.fixture(autouse=True) def _refresh_bindings_against_live_module(): - """Rebind module-level names to the *current* ``hermes_cli.main``. + """Rebind module-level names to the *current* defining modules. - Other tests in the suite (notably ``test_env_loader.py`` and - ``test_skills_subparser.py``) reload or delete ``hermes_cli.main`` from - ``sys.modules``. When that happens on the same xdist worker before we - run, our top-of-file ``from hermes_cli.main import ...`` bindings end - up pointing at the *old* module object. ``patch(\"hermes_cli.main.X\")`` - then patches the *new* module, but the function we call still resolves - ``_find_stale_dashboard_pids`` via its stale ``__globals__``, so every - patch becomes a no-op and the kill path silently returns early. - - Refreshing the bindings (and the patch target) to the live module - object — and keeping them consistent — makes the tests immune to - ordering within the worker. The fix lives in the test module because - the two pollutants above are load-bearing for their own tests. + Other tests in the suite reload modules from ``sys.modules``; when that + happens on the same xdist worker before we run, our top-of-file bindings + end up pointing at the *old* module object and ``patch(".X")`` + patches the *new* one, so every patch becomes a no-op and the kill path + silently returns early. Refreshing the bindings keeps them consistent. """ global _finish_dashboard_update_cleanup global _find_stale_dashboard_pids @@ -55,15 +48,11 @@ def _refresh_bindings_against_live_module(): global _restart_managed_dashboard_service global _warn_stale_dashboard_processes - live = sys.modules.get("hermes_cli.main") - if live is None: - live = importlib.import_module("hermes_cli.main") - - _finish_dashboard_update_cleanup = live._finish_dashboard_update_cleanup - _find_stale_dashboard_pids = live._find_stale_dashboard_pids - _kill_stale_dashboard_processes = live._kill_stale_dashboard_processes - _restart_managed_dashboard_service = live._restart_managed_dashboard_service - _warn_stale_dashboard_processes = live._warn_stale_dashboard_processes + _finish_dashboard_update_cleanup = update_cmd._finish_dashboard_update_cleanup + _find_stale_dashboard_pids = main_dashboard._find_stale_dashboard_pids + _kill_stale_dashboard_processes = dashboard_procs._kill_stale_dashboard_processes + _restart_managed_dashboard_service = main_dashboard._restart_managed_dashboard_service + _warn_stale_dashboard_processes = dashboard_procs._kill_stale_dashboard_processes yield @@ -122,7 +111,7 @@ def test_update_cleanup_spares_backend_owned_by_valid_ssh_lock(tmp_path, monkeyp return [] with patch( - "hermes_cli.main._find_stale_dashboard_pids", + "hermes_cli.main_dashboard._find_stale_dashboard_pids", side_effect=assert_owned_pid_is_excluded, ): result = _kill_stale_dashboard_processes(restart_managed=True) @@ -141,7 +130,7 @@ def test_explicit_stop_does_not_spare_backend_owned_by_valid_ssh_lock( return [] with patch( - "hermes_cli.main._find_stale_dashboard_pids", + "hermes_cli.main_dashboard._find_stale_dashboard_pids", side_effect=assert_owned_pid_is_not_excluded, ): result = _kill_stale_dashboard_processes(restart_managed=False) @@ -205,7 +194,7 @@ class TestKillStaleDashboardPosix: raise ProcessLookupError # SIGTERM itself: succeed silently. - with patch("hermes_cli.main._find_stale_dashboard_pids", + with patch("hermes_cli.main_dashboard._find_stale_dashboard_pids", return_value=[12345, 12346]), \ patch("os.kill", side_effect=fake_kill), \ patch("time.sleep"): @@ -255,7 +244,7 @@ class TestKillStaleDashboardPosix: raise AssertionError(f"unexpected subprocess.run call: {args}") with patch("subprocess.run", side_effect=fake_run), \ - patch("hermes_cli.main._find_stale_dashboard_pids", return_value=[]) as find_pids, \ + patch("hermes_cli.main_dashboard._find_stale_dashboard_pids", return_value=[]) as find_pids, \ patch("os.kill") as kill: _kill_stale_dashboard_processes(restart_managed=True) @@ -289,7 +278,7 @@ class TestKillStaleDashboardWindows: # taskkill returns 0 on success return MagicMock(returncode=0, stdout="", stderr="") - with patch("hermes_cli.main._find_stale_dashboard_pids", + with patch("hermes_cli.main_dashboard._find_stale_dashboard_pids", return_value=[12345, 12346]), \ patch("gateway.status.get_process_start_time", return_value=123), \ patch("hermes_cli._subprocess_compat.pid_is_hermes", return_value=True), \ @@ -391,7 +380,7 @@ class TestSupervisedBackendRestart: Restart=on-failure never fires on its own.""" def _live(self): - return sys.modules["hermes_cli.main"] + return main_dashboard def test_supervised_pid_restarts_owning_unit(self, capsys): """A killed PID whose cgroup names a custom unit → systemctl restart.""" @@ -401,13 +390,13 @@ class TestSupervisedBackendRestart: if sig == 0: raise ProcessLookupError - with patch.object(live, "_restart_managed_dashboard_service", return_value=False), \ + with patch.object(main_dashboard, "_restart_managed_dashboard_service", return_value=False), \ patch.object(live, "_find_stale_dashboard_pids", return_value=[4321]), \ - patch.object(live, "_get_pid_cgroup_path", + patch.object(main_dashboard, "_get_pid_cgroup_path", return_value="/system.slice/hermes-serve.service"), \ - patch.object(live, "_get_systemd_service_for_pid", + patch.object(main_dashboard, "_get_systemd_service_for_pid", return_value="hermes-serve.service"), \ - patch.object(live, "_try_restart_systemd_service", return_value=True) as restart, \ + patch.object(main_dashboard, "_try_restart_systemd_service", return_value=True) as restart, \ patch("os.kill", side_effect=fake_kill), \ patch("time.sleep"): _kill_stale_dashboard_processes(restart_managed=True) @@ -427,13 +416,13 @@ class TestSupervisedBackendRestart: skip killing/restarting it again here.""" live = self._live() - with patch.object(live, "_restart_managed_dashboard_service", return_value=False), \ + with patch.object(main_dashboard, "_restart_managed_dashboard_service", return_value=False), \ patch.object(live, "_find_stale_dashboard_pids", return_value=[4321]), \ - patch.object(live, "_get_pid_cgroup_path", + patch.object(main_dashboard, "_get_pid_cgroup_path", return_value="/system.slice/hermes-serve.service"), \ - patch.object(live, "_get_systemd_service_for_pid", + patch.object(main_dashboard, "_get_systemd_service_for_pid", return_value="hermes-serve.service"), \ - patch.object(live, "_try_restart_systemd_service") as restart, \ + patch.object(main_dashboard, "_try_restart_systemd_service") as restart, \ patch("os.kill") as kill, \ patch("time.sleep"): result = _kill_stale_dashboard_processes( @@ -450,7 +439,7 @@ class TestManualBackendRespawn: kill and are respawned detached after the update (#40449).""" def _live(self): - return sys.modules["hermes_cli.main"] + return main_dashboard @pytest.mark.skipif(sys.platform == "win32", reason="POSIX cmdline capture + respawn") @@ -461,11 +450,11 @@ class TestManualBackendRespawn: if sig == 0: raise ProcessLookupError - with patch.object(live, "_restart_managed_dashboard_service", return_value=False), \ + with patch.object(main_dashboard, "_restart_managed_dashboard_service", return_value=False), \ patch.object(live, "_find_stale_dashboard_pids", return_value=[5555]), \ - patch.object(live, "_get_pid_cgroup_path", return_value=None), \ - patch.object(live, "_get_systemd_service_for_pid", return_value=None), \ - patch.object(live, "_dashboard_cmdline_for_pid", return_value=None), \ + patch.object(main_dashboard, "_get_pid_cgroup_path", return_value=None), \ + patch.object(main_dashboard, "_get_systemd_service_for_pid", return_value=None), \ + patch.object(main_dashboard, "_dashboard_cmdline_for_pid", return_value=None), \ patch.object(live, "_respawn_dashboard_processes") as respawn, \ patch("os.kill", side_effect=fake_kill), \ patch("time.sleep"): @@ -485,11 +474,11 @@ class TestManualBackendRespawn: if sig == 0: raise ProcessLookupError - with patch.object(live, "_restart_managed_dashboard_service", return_value=False), \ + with patch.object(main_dashboard, "_restart_managed_dashboard_service", return_value=False), \ patch.object(live, "_find_stale_dashboard_pids", return_value=[6001]), \ - patch.object(live, "_get_pid_cgroup_path", return_value=None), \ - patch.object(live, "_get_systemd_service_for_pid", return_value=None), \ - patch.object(live, "_dashboard_cmdline_for_pid", return_value=argv), \ + patch.object(main_dashboard, "_get_pid_cgroup_path", return_value=None), \ + patch.object(main_dashboard, "_get_systemd_service_for_pid", return_value=None), \ + patch.object(main_dashboard, "_dashboard_cmdline_for_pid", return_value=argv), \ patch("hermes_cli.dashboard_procs._hermes_home_for_pid", return_value=None), \ patch.object(live, "_respawn_dashboard_processes", return_value=[]) as respawn, \ patch("os.kill", side_effect=fake_kill), \ @@ -512,12 +501,12 @@ class TestManualBackendRespawn: if sig == 0: raise ProcessLookupError - with patch.object(live, "_restart_managed_dashboard_service", return_value=False), \ + with patch.object(main_dashboard, "_restart_managed_dashboard_service", return_value=False), \ patch.object(live, "_find_stale_dashboard_pids", return_value=[7001, 7002, 7003]), \ - patch.object(live, "_get_pid_cgroup_path", return_value=None), \ - patch.object(live, "_get_systemd_service_for_pid", return_value=None), \ - patch.object(live, "_dashboard_cmdline_for_pid", return_value=argv), \ + patch.object(main_dashboard, "_get_pid_cgroup_path", return_value=None), \ + patch.object(main_dashboard, "_get_systemd_service_for_pid", return_value=None), \ + patch.object(main_dashboard, "_dashboard_cmdline_for_pid", return_value=argv), \ patch("hermes_cli.dashboard_procs._hermes_home_for_pid", return_value=None), \ patch.object(live, "_respawn_dashboard_processes") as respawn, \ patch("os.kill", side_effect=fake_kill), \ @@ -540,11 +529,11 @@ class TestManualBackendRespawn: if sig == 0: raise ProcessLookupError - with patch.object(live, "_restart_managed_dashboard_service", return_value=False), \ + with patch.object(main_dashboard, "_restart_managed_dashboard_service", return_value=False), \ patch.object(live, "_find_stale_dashboard_pids", return_value=[8001]), \ - patch.object(live, "_get_pid_cgroup_path", return_value=None), \ - patch.object(live, "_get_systemd_service_for_pid", return_value=None), \ - patch.object(live, "_dashboard_cmdline_for_pid", return_value=argv), \ + patch.object(main_dashboard, "_get_pid_cgroup_path", return_value=None), \ + patch.object(main_dashboard, "_get_systemd_service_for_pid", return_value=None), \ + patch.object(main_dashboard, "_dashboard_cmdline_for_pid", return_value=argv), \ patch("hermes_cli.dashboard_procs._hermes_home_for_pid", return_value=None), \ patch.object(live, "_respawn_dashboard_processes", return_value=[]) as respawn, \ patch("os.kill", side_effect=fake_kill), \ @@ -806,7 +795,7 @@ class TestCmdlineCapture: """_dashboard_cmdline_for_pid reads /proc on Linux, ps on macOS.""" def _live(self): - return sys.modules["hermes_cli.main"] + return main_dashboard @pytest.mark.skipif(sys.platform == "win32", reason="POSIX /proc cmdline path") def test_reads_proc_cmdline_when_available(self, tmp_path, monkeypatch): @@ -830,7 +819,7 @@ class TestCmdlineCapture: with patch.object(live.os.path, "exists", fake_exists), \ patch("builtins.open", fake_open): - argv = live._dashboard_cmdline_for_pid(777) + argv = main_dashboard._dashboard_cmdline_for_pid(777) assert argv == ["/usr/bin/python3", "-m", "hermes_cli.main", "serve"] @@ -844,7 +833,7 @@ class TestCmdlineCapture: with patch.object(live.os.path, "exists", return_value=False), \ patch("subprocess.run", side_effect=fake_run): - argv = live._dashboard_cmdline_for_pid(888) + argv = main_dashboard._dashboard_cmdline_for_pid(888) assert argv == ["hermes", "serve", "--port", "8300"] @@ -855,7 +844,7 @@ class TestCmdlineCapture: restated the branch condition. """ live = self._live() - assert live._dashboard_cmdline_for_pid(123) is None + assert main_dashboard._dashboard_cmdline_for_pid(123) is None class TestPostUpdateStaleModuleReload: diff --git a/tests/hermes_cli/test_update_stale_virtualenv.py b/tests/hermes_cli/test_update_stale_virtualenv.py index 8782d94d4b..d333c9afa6 100644 --- a/tests/hermes_cli/test_update_stale_virtualenv.py +++ b/tests/hermes_cli/test_update_stale_virtualenv.py @@ -10,6 +10,7 @@ from pathlib import Path from unittest import mock import hermes_cli.main as main_mod +from hermes_cli import main_install_repair class StaleVirtualEnvTest(unittest.TestCase): @@ -24,13 +25,13 @@ class StaleVirtualEnvTest(unittest.TestCase): def fake_verify(prefix, *, env=None): return None - with mock.patch.object(main_mod, "_run_quarantined_install", fake_quarantine), \ - mock.patch.object(main_mod, "_verify_console_scripts_installed", fake_verify), \ - mock.patch.object(main_mod, "_venv_scripts_dir", return_value=None), \ - mock.patch.object(main_mod, "_is_windows", return_value=is_windows), \ - mock.patch.object(main_mod.sys, "executable", fake_executable), \ + with mock.patch.object(main_install_repair, "_run_quarantined_install", fake_quarantine), \ + mock.patch.object(main_install_repair, "_verify_console_scripts_installed", fake_verify), \ + mock.patch.object(main_install_repair, "_venv_scripts_dir", return_value=None), \ + mock.patch.object(main_install_repair, "_is_windows", return_value=is_windows), \ + mock.patch.object(main_install_repair.sys, "executable", fake_executable), \ mock.patch.object(main_mod, "PROJECT_ROOT", Path("/fake/project")): - main_mod._install_python_dependencies_with_optional_fallback( + main_install_repair._install_python_dependencies_with_optional_fallback( list(uv_cmd), env={"VIRTUAL_ENV": str(venv_path)}, group="all", @@ -88,7 +89,7 @@ class StaleVirtualEnvTest(unittest.TestCase): ) # Force the caller path through a manual pin with a pre-existing flag. args = ["install", "--python", "/caller/choice/python.exe", "hermes"] - pinned = main_mod._insert_python_pin(args) + pinned = main_install_repair._insert_python_pin(args) self.assertEqual(pinned, args, "existing --python must win") self.assertEqual(pinned.count("--python"), 1) @@ -96,8 +97,7 @@ class StaleVirtualEnvTest(unittest.TestCase): """On Windows with a missing project venv, quarantine must target the interpreter's Scripts dir (where the shims actually live), not None.""" fake_scripts = Path("/fake/python311/Scripts") - with mock.patch.object( - main_mod, "_interpreter_scripts_dir", return_value=fake_scripts + with mock.patch.object(main_install_repair, "_interpreter_scripts_dir", return_value=fake_scripts ): captured = self._call( uv_cmd=[Path("/fake/uv"), "pip"], diff --git a/tests/hermes_cli/test_update_venv_health.py b/tests/hermes_cli/test_update_venv_health.py index f98b92f35e..77f0dc4ac1 100644 --- a/tests/hermes_cli/test_update_venv_health.py +++ b/tests/hermes_cli/test_update_venv_health.py @@ -24,6 +24,7 @@ from unittest.mock import MagicMock, patch import pytest from hermes_cli import main as cli_main +from hermes_cli import update_cmd # --------------------------------------------------------------------------- @@ -200,7 +201,7 @@ def _run_update_until_guard(args): cli_main, "PROJECT_ROOT", _RootSentinel() ): try: - cli_main._cmd_update_impl(args, gateway_mode=False) + update_cmd._cmd_update_impl(args, gateway_mode=False) except _PastGuard: return "past_guard" except SystemExit as exc: diff --git a/tests/hermes_cli/test_update_zip_atomic_replace.py b/tests/hermes_cli/test_update_zip_atomic_replace.py index 4de8505034..51eaa5ba64 100644 --- a/tests/hermes_cli/test_update_zip_atomic_replace.py +++ b/tests/hermes_cli/test_update_zip_atomic_replace.py @@ -16,7 +16,7 @@ from pathlib import Path import pytest -from hermes_cli.main import _atomic_replace_dir +from hermes_cli.update_cmd import _atomic_replace_dir def test_atomic_replace_swaps_content_on_success(tmp_path: Path) -> None: diff --git a/tests/hermes_cli/test_update_zip_fallback_guards.py b/tests/hermes_cli/test_update_zip_fallback_guards.py index d28caaf99d..61484de2cd 100644 --- a/tests/hermes_cli/test_update_zip_fallback_guards.py +++ b/tests/hermes_cli/test_update_zip_fallback_guards.py @@ -179,7 +179,7 @@ def test_update_via_zip_aborts_before_download_when_dirty( with patch("urllib.request.urlretrieve") as download: with pytest.raises(SystemExit) as exc_info: - hermes_main._update_via_zip(SimpleNamespace(branch=None)) + update_cmd._update_via_zip(SimpleNamespace(branch=None)) assert exc_info.value.code == 1 download.assert_not_called() diff --git a/tests/hermes_cli/test_update_zip_symlink_reject.py b/tests/hermes_cli/test_update_zip_symlink_reject.py index 72359bcd54..88b615c091 100644 --- a/tests/hermes_cli/test_update_zip_symlink_reject.py +++ b/tests/hermes_cli/test_update_zip_symlink_reject.py @@ -14,6 +14,7 @@ import zipfile from unittest.mock import patch import pytest +from hermes_cli import update_cmd def _build_zip_with_symlink_member(zip_path: str, link_name: str, target: str) -> None: @@ -45,7 +46,7 @@ def test_update_via_zip_rejects_symlink_member(tmp_path, monkeypatch): fake_root.mkdir() from hermes_cli import main as hermes_main - from hermes_cli.main import _update_via_zip + from hermes_cli.update_cmd import _update_via_zip monkeypatch.setattr(hermes_main, "PROJECT_ROOT", fake_root) @@ -124,7 +125,7 @@ def test_update_via_zip_accepts_normal_member(tmp_path, monkeypatch, capsys): patch("subprocess.check_call"): fake_run.return_value = type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})() try: - hermes_main._update_via_zip(args) + update_cmd._update_via_zip(args) except SystemExit: pass diff --git a/tests/hermes_cli/test_update_zip_two_phase.py b/tests/hermes_cli/test_update_zip_two_phase.py index 29610f9ae3..c0750a1553 100644 --- a/tests/hermes_cli/test_update_zip_two_phase.py +++ b/tests/hermes_cli/test_update_zip_two_phase.py @@ -19,6 +19,7 @@ import pytest from hermes_cli import update_cmd from hermes_constants import venv_bin_dir, venv_python_path +from hermes_cli import main_install_repair # --------------------------------------------------------------------------- @@ -313,7 +314,7 @@ def test_venv_helpers_honour_an_explicit_platform_verdict(): """Callers must be able to override the platform check (#76107 CI). The suite exercises Windows paths on Linux CI by patching predicates like - `hermes_cli.main._is_windows`. A helper that reads `sys.platform` + `hermes_main._is_windows`. A helper that reads `sys.platform` unconditionally silently drops those paths out of coverage -- and broke `test_verify_core_dependencies.py::test_uses_virtual_env_from_environment`, which patches `_is_windows` and then asserts on a `Scripts/python.exe` diff --git a/tests/hermes_cli/test_verify_console_scripts.py b/tests/hermes_cli/test_verify_console_scripts.py index 45f073f40b..f7106d10c5 100644 --- a/tests/hermes_cli/test_verify_console_scripts.py +++ b/tests/hermes_cli/test_verify_console_scripts.py @@ -7,6 +7,7 @@ from pathlib import Path from unittest.mock import patch import pytest +from hermes_cli import main_install_repair @pytest.fixture @@ -44,10 +45,10 @@ class TestVerifyConsoleScriptsInstalled: for name in ("hermes", "hermes-agent", "hermes-acp"): (fake_scripts_dir / f"{name}.exe").write_bytes(b"fake") - with patch("hermes_cli.main._is_windows", return_value=True), \ - patch("hermes_cli.main._venv_scripts_dir", return_value=fake_scripts_dir), \ - patch("hermes_cli.main._run_quarantined_install") as mock_install: - from hermes_cli.main import _verify_console_scripts_installed + with patch("hermes_cli.main_install_repair._is_windows", return_value=True), \ + patch("hermes_cli.main_install_repair._venv_scripts_dir", return_value=fake_scripts_dir), \ + patch("hermes_cli.main_install_repair._run_quarantined_install") as mock_install: + from hermes_cli.main_install_repair import _verify_console_scripts_installed _verify_console_scripts_installed(["uv", "pip"], env={}) @@ -61,8 +62,8 @@ class TestVerifyConsoleScriptsInstalled: ): import hermes_cli.main as main_mod - with patch("hermes_cli.main._is_windows", return_value=True): - names = {path.name for path in main_mod._hermes_exe_shims(fake_scripts_dir)} + with patch("hermes_cli.main_install_repair._is_windows", return_value=True): + names = {path.name for path in main_install_repair._hermes_exe_shims(fake_scripts_dir)} assert {"hermes.exe", "hermes-agent.exe", "hermes-acp.exe"} <= names assert "hermes-gateway.exe" in names diff --git a/tests/hermes_cli/test_verify_core_dependencies.py b/tests/hermes_cli/test_verify_core_dependencies.py index 0404d2bb0e..3c5c14f043 100644 --- a/tests/hermes_cli/test_verify_core_dependencies.py +++ b/tests/hermes_cli/test_verify_core_dependencies.py @@ -84,11 +84,11 @@ class TestVerifyCoreDependencies: captured_argv.append(list(cmd)) return MagicMock(returncode=0, stdout="", stderr="") - with patch("hermes_cli.main._resolve_install_target_python", return_value=py), \ - patch("hermes_cli.main.subprocess.run", side_effect=fake_subprocess_run), \ - patch("hermes_cli.main._run_install_with_heartbeat"): + with patch("hermes_cli.main_install_repair._resolve_install_target_python", return_value=py), \ + patch("hermes_cli.main_install_repair.subprocess.run", side_effect=fake_subprocess_run), \ + patch("hermes_cli.main_install_repair._run_install_with_heartbeat"): - from hermes_cli.main import _verify_core_dependencies_installed + from hermes_cli.main_install_repair import _verify_core_dependencies_installed _verify_core_dependencies_installed(["uv", "pip"], env=env) # Find the probe argv — it's the call that passed the dep names. @@ -116,9 +116,9 @@ class TestVerifyCoreDependencies: import hermes_cli.main as main_mod monkeypatch.setattr(main_mod, "PROJECT_ROOT", tmp_path) # No pyproject.toml in tmp_path. - with patch("hermes_cli.main._resolve_install_target_python") as mock_resolve, \ - patch("hermes_cli.main._run_install_with_heartbeat") as mock_install: - from hermes_cli.main import _verify_core_dependencies_installed + with patch("hermes_cli.main_install_repair._resolve_install_target_python") as mock_resolve, \ + patch("hermes_cli.main_install_repair._run_install_with_heartbeat") as mock_install: + from hermes_cli.main_install_repair import _verify_core_dependencies_installed _verify_core_dependencies_installed(["uv", "pip"], env={}) assert not mock_resolve.called assert not mock_install.called @@ -137,8 +137,8 @@ class TestResolveInstallTargetPython: py = scripts / "python.exe" py.write_text("fake") - with patch("hermes_cli.main._is_windows", return_value=True): - from hermes_cli.main import _resolve_install_target_python + with patch("hermes_cli.main_install_repair._is_windows", return_value=True): + from hermes_cli.main_install_repair import _resolve_install_target_python result = _resolve_install_target_python( ["uv", "pip"], env={"VIRTUAL_ENV": str(venv_root)} ) @@ -148,8 +148,8 @@ class TestResolveInstallTargetPython: """If the path we'd point at doesn't exist (uv install failed before the python shim landed), return None so the verification step cleanly short-circuits instead of crashing on FileNotFoundError.""" - with patch("hermes_cli.main._is_windows", return_value=True): - from hermes_cli.main import _resolve_install_target_python + with patch("hermes_cli.main_install_repair._is_windows", return_value=True): + from hermes_cli.main_install_repair import _resolve_install_target_python result = _resolve_install_target_python( ["uv", "pip"], env={"VIRTUAL_ENV": str(tmp_path / "does_not_exist")} ) diff --git a/tests/hermes_cli/test_web_ui_build.py b/tests/hermes_cli/test_web_ui_build.py index e1b05d183b..91a96c4502 100644 --- a/tests/hermes_cli/test_web_ui_build.py +++ b/tests/hermes_cli/test_web_ui_build.py @@ -18,17 +18,9 @@ from unittest.mock import patch import pytest -from hermes_cli.main import ( - _web_ui_build_needed, - _build_web_ui, - _compute_web_ui_content_hash, - _missing_web_build_tool, - _run_npm_install_deterministic, - _web_build_toolchain_ready, - _web_toolchain_roots, - _web_ui_stamp_path, - _write_web_ui_build_stamp, -) +from hermes_cli.main_web_build import _build_web_ui, _run_npm_install_deterministic +from hermes_cli.main_web_build import _web_ui_build_needed, _compute_web_ui_content_hash, _missing_web_build_tool, _web_ui_stamp_path, _write_web_ui_build_stamp +from hermes_cli.update_cmd import _web_build_toolchain_ready, _web_toolchain_roots @pytest.fixture(autouse=True) @@ -147,7 +139,7 @@ class TestBuildWebUISkipsWhenFresh: build_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="") with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ patch("hermes_cli.main.subprocess.run", return_value=install_cp) as mock_run, \ - patch("hermes_cli.main._run_with_idle_timeout", return_value=build_cp) as mock_build: + patch("hermes_cli.main_web_build._run_with_idle_timeout", return_value=build_cp) as mock_build: result = _build_web_ui(web_dir) assert result is True @@ -180,7 +172,7 @@ class TestBuildWebUISkipsWhenFresh: build_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="") with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ patch("hermes_cli.main.subprocess.run", return_value=install_cp) as mock_run, \ - patch("hermes_cli.main._run_with_idle_timeout", return_value=build_cp): + patch("hermes_cli.main_web_build._run_with_idle_timeout", return_value=build_cp): result = _build_web_ui(web_dir) assert result is True @@ -203,7 +195,7 @@ class TestBuildWebUISkipsWhenFresh: build_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="") with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ patch("hermes_cli.main.subprocess.run", return_value=install_cp) as mock_run, \ - patch("hermes_cli.main._run_with_idle_timeout", return_value=build_cp): + patch("hermes_cli.main_web_build._run_with_idle_timeout", return_value=build_cp): result = _build_web_ui(web_dir) assert result is True @@ -225,7 +217,7 @@ class TestBuildWebUISkipsWhenFresh: build_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="") with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ patch("hermes_cli.main.subprocess.run", return_value=install_cp), \ - patch("hermes_cli.main._run_with_idle_timeout", return_value=build_cp) as mock_idle: + patch("hermes_cli.main_web_build._run_with_idle_timeout", return_value=build_cp) as mock_idle: result = _build_web_ui(web_dir) assert result is True @@ -248,9 +240,9 @@ class TestBuildWebUIRetryAndStaleFallback: build_fail = Subprocess.CompletedProcess([], 1, stdout="EPERM", stderr="") build_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="") with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ - patch("hermes_cli.main._time.sleep") as mock_sleep, \ + patch("hermes_cli.main_web_build._time.sleep") as mock_sleep, \ patch("hermes_cli.main.subprocess.run", return_value=install_ok), \ - patch("hermes_cli.main._run_with_idle_timeout", + patch("hermes_cli.main_web_build._run_with_idle_timeout", side_effect=[build_fail, build_ok]) as mock_idle: result = _build_web_ui(web_dir) @@ -268,9 +260,9 @@ class TestBuildWebUIRetryAndStaleFallback: install_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="") build_fail = Subprocess.CompletedProcess([], 1, stdout="vite ENOMEM", stderr="") with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ - patch("hermes_cli.main._time.sleep"), \ + patch("hermes_cli.main_web_build._time.sleep"), \ patch("hermes_cli.main.subprocess.run", return_value=install_ok), \ - patch("hermes_cli.main._run_with_idle_timeout", + patch("hermes_cli.main_web_build._run_with_idle_timeout", side_effect=[build_fail, build_fail]): result = _build_web_ui(web_dir, fatal=True) @@ -300,7 +292,7 @@ class TestBuildWebUIFlock: the winner's output and skips a duplicate build.""" import fcntl import threading - from hermes_cli.main import _build_web_ui as build + from hermes_cli.main_web_build import _build_web_ui as build web_dir, dist_dir = _make_web_dir(tmp_path) # No dist yet — contender must take the blocking-wait path. @@ -396,12 +388,12 @@ class TestBuildRecoversFromMissingToolchain: ) build_ok = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="") - with patch("hermes_cli.main._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \ - patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok) as mock_install, \ - patch("hermes_cli.main._run_with_idle_timeout", side_effect=[build_fail, build_ok]) as mock_build, \ - patch("hermes_cli.main._web_ui_build_needed", return_value=True), \ - patch("hermes_cli.main._write_web_ui_build_stamp"), \ - patch("hermes_cli.main._time.sleep"): + with patch("hermes_cli.main_install_repair._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main_web_build._run_npm_install_deterministic", return_value=install_ok) as mock_install, \ + patch("hermes_cli.main_web_build._run_with_idle_timeout", side_effect=[build_fail, build_ok]) as mock_build, \ + patch("hermes_cli.main_web_build._web_ui_build_needed", return_value=True), \ + patch("hermes_cli.main_web_build._write_web_ui_build_stamp"), \ + patch("hermes_cli.main_web_build._time.sleep"): result = _build_web_ui(web_dir) assert result is True @@ -416,11 +408,11 @@ class TestBuildRecoversFromMissingToolchain: install_ok = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="") build_ok = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="") - with patch("hermes_cli.main._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \ - patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok) as mock_install, \ - patch("hermes_cli.main._run_with_idle_timeout", return_value=build_ok) as mock_build, \ - patch("hermes_cli.main._web_ui_build_needed", return_value=True), \ - patch("hermes_cli.main._write_web_ui_build_stamp"): + with patch("hermes_cli.main_install_repair._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main_web_build._run_npm_install_deterministic", return_value=install_ok) as mock_install, \ + patch("hermes_cli.main_web_build._run_with_idle_timeout", return_value=build_ok) as mock_build, \ + patch("hermes_cli.main_web_build._web_ui_build_needed", return_value=True), \ + patch("hermes_cli.main_web_build._write_web_ui_build_stamp"): result = _build_web_ui(web_dir) assert result is True diff --git a/tests/hermes_cli/test_whatsapp_setup_ordering.py b/tests/hermes_cli/test_whatsapp_setup_ordering.py index 0585bd0ff3..d8f568b743 100644 --- a/tests/hermes_cli/test_whatsapp_setup_ordering.py +++ b/tests/hermes_cli/test_whatsapp_setup_ordering.py @@ -54,7 +54,7 @@ def test_aborted_setup_does_not_enable_whatsapp(isolated_home, monkeypatch): WHATSAPP_ENABLED must NOT be present in .env after abort. """ - from hermes_cli.main import cmd_whatsapp + from hermes_cli.main_platform_setup import cmd_whatsapp # First input() = mode choice, second input() = allowed-users prompt # We raise KeyboardInterrupt on the second call to simulate abort. @@ -90,7 +90,7 @@ def test_existing_pairing_skip_branch_enables_whatsapp(isolated_home, monkeypatc should be (re-)written to true so the gateway picks WhatsApp back up, even if the var was lost since the original pairing. """ - from hermes_cli.main import cmd_whatsapp + from hermes_cli.main_platform_setup import cmd_whatsapp # Pre-create a paired session WITHOUT WHATSAPP_ENABLED in .env. session = isolated_home / "whatsapp" / "session" diff --git a/tests/hermes_state/test_session_lifecycle_status.py b/tests/hermes_state/test_session_lifecycle_status.py index e56b2e8266..2d9a4a8a7d 100644 --- a/tests/hermes_state/test_session_lifecycle_status.py +++ b/tests/hermes_state/test_session_lifecycle_status.py @@ -147,7 +147,7 @@ def test_session_lifecycle_statuses_unknown_id(db): # --------------------------------------------------------------------------- def test_annotate_session_statuses(db): - from hermes_cli.main import _annotate_session_statuses, _session_status_tag + from hermes_cli.sessions_cmd_browse import _annotate_session_statuses, _session_status_tag db.create_session("s1", source="cli") db.append_message("s1", "user", "hi") diff --git a/tests/honcho_plugin/test_query_rewrite.py b/tests/honcho_plugin/test_query_rewrite.py index 3c846e7051..233f9adcbd 100644 --- a/tests/honcho_plugin/test_query_rewrite.py +++ b/tests/honcho_plugin/test_query_rewrite.py @@ -13,7 +13,7 @@ from plugins.memory.query_rewrite import ( rewrite_memory_query, ) from hermes_cli.config import DEFAULT_CONFIG -from hermes_cli.main import _AUX_TASKS +from hermes_cli.main_provider_setup import _AUX_TASKS def _response(text: str): diff --git a/tests/test_lazy_secrets_dispatch.py b/tests/test_lazy_secrets_dispatch.py index 61daf44562..e48a613588 100644 --- a/tests/test_lazy_secrets_dispatch.py +++ b/tests/test_lazy_secrets_dispatch.py @@ -17,6 +17,7 @@ import sys from pathlib import Path import pytest +from hermes_cli import update_cmd def _run_hermes(args: list[str], timeout: int = 30) -> subprocess.CompletedProcess[str]: @@ -187,8 +188,8 @@ import hermes_cli.main as m # without doing network I/O. cmd_update (in main.py) calls # _self()._cmd_update_check(branch=..., branch_explicit=...) where _self() # resolves the hermes_cli.main module's lazily re-exported attribute — -# so the patch must land on hermes_cli.main._cmd_update_check. -with patch('hermes_cli.main._cmd_update_check', capture_update_check): +# so the patch must land on update_cmd._cmd_update_check. +with patch('hermes_cli.update_cmd._cmd_update_check', capture_update_check): try: m.main() except SystemExit as e: