From adba16445af7e3fe7dffa2917d3feb53ed8bd358 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:27:53 -0700 Subject: [PATCH 1/5] refactor(hermes_cli): AST-neutral layout compaction of update_cmd*/uninstall (hug brackets, pack args) --- hermes_cli/uninstall.py | 53 ++-- hermes_cli/update_abort_recovery.py | 54 ++-- hermes_cli/update_cmd.py | 390 ++++++++-------------------- hermes_cli/update_cmd_config.py | 51 ++-- hermes_cli/update_cmd_deps.py | 181 ++++--------- 5 files changed, 203 insertions(+), 526 deletions(-) diff --git a/hermes_cli/uninstall.py b/hermes_cli/uninstall.py index 7586b439e8..cd4433558e 100644 --- a/hermes_cli/uninstall.py +++ b/hermes_cli/uninstall.py @@ -133,8 +133,7 @@ def remove_wrapper_script(): candidates = ( bin_dir / name for bin_dir in (Path.home() / ".local" / "bin", Path("/usr/local/bin")) - for name in ("hermes", "hermes-acp", "hermes-agent") - ) + for name in ("hermes", "hermes-acp", "hermes-agent")) return _remove_each((w for w in candidates if w.exists()), _unlink_ours) @@ -220,10 +219,7 @@ def uninstall_gateway_service(): def _remove_systemd_gateway() -> bool: """Linux: uninstall systemd services (both user and system scopes).""" from hermes_cli.gateway import ( - get_systemd_unit_path, - get_service_name, - _systemctl_cmd, - ) + get_systemd_unit_path, get_service_name, _systemctl_cmd) svc_name = get_service_name() removed_any = False @@ -254,8 +250,7 @@ def _remove_launchd_gateway() -> bool: plist_path = get_launchd_plist_path() if not plist_path.exists(): return False - subprocess.run(["launchctl", "unload", str(plist_path)], - capture_output=True, check=False) + subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True, check=False) plist_path.unlink() log_success(f"Removed macOS gateway service ({plist_path})") return True @@ -290,9 +285,7 @@ def _remove_windows_gateway() -> bool: _GATEWAY_SERVICE_REMOVERS = { "Linux": (_remove_systemd_gateway, "Could not check systemd gateway services"), "Darwin": (_remove_launchd_gateway, "Could not remove launchd gateway service"), - "Windows": (_remove_windows_gateway, "Could not check Windows gateway service"), -} - + "Windows": (_remove_windows_gateway, "Could not check Windows gateway service")} # ============================================================================ # Windows-specific uninstall helpers @@ -493,11 +486,8 @@ def _uninstall_profile(profile) -> None: for subcmd in ("stop", "uninstall"): try: subprocess.run( - hermes_invocation + ["gateway", subcmd], - capture_output=True, - text=True, encoding='utf-8', errors='replace', - timeout=60, - check=False, + hermes_invocation + ["gateway", subcmd], capture_output=True, text=True, + encoding='utf-8', errors='replace', timeout=60, check=False, ) except subprocess.TimeoutExpired: log_warn(f" Gateway {subcmd} timed out for '{name}'") @@ -525,10 +515,7 @@ def run_gui_uninstall(args): config/sessions/.env, and never the Python agent or its venv. """ from hermes_cli.gui_uninstall import ( - agent_is_installed, - gui_install_summary, - uninstall_gui, - ) + agent_is_installed, gui_install_summary, uninstall_gui) hermes_home = get_hermes_home() summary = gui_install_summary(hermes_home) @@ -586,8 +573,7 @@ def run_uninstall(args): _print_uninstall_dry_run( project_root=project_root, hermes_home=hermes_home, - full_uninstall=bool(getattr(args, "full", False)), - ) + full_uninstall=bool(getattr(args, "full", False))) return # Detect named profiles when uninstalling from the default root — @@ -604,10 +590,8 @@ def run_uninstall(args): # lite/full modes. if bool(getattr(args, "yes", False)): _perform_uninstall( - project_root=project_root, - hermes_home=hermes_home, - full_uninstall=bool(getattr(args, "full", False)), - remove_profiles=False, + project_root=project_root, hermes_home=hermes_home, + full_uninstall=bool(getattr(args, "full", False)), remove_profiles=False, named_profiles=named_profiles, ) return @@ -684,11 +668,8 @@ def run_uninstall(args): return _perform_uninstall( - project_root=project_root, - hermes_home=hermes_home, - full_uninstall=full_uninstall, - remove_profiles=remove_profiles, - named_profiles=named_profiles, + project_root=project_root, hermes_home=hermes_home, full_uninstall=full_uninstall, + remove_profiles=remove_profiles, named_profiles=named_profiles, ) @@ -744,8 +725,7 @@ def _perform_uninstall( hermes_home: Path, full_uninstall: bool, remove_profiles: bool, - named_profiles: list, -) -> None: + named_profiles: list) -> None: """Execute the uninstall steps; shared by the interactive and ``--yes`` paths. Order: stop gateway -> strip PATH (rc files + Windows registry) -> remove wrapper and node @@ -813,8 +793,7 @@ def _perform_uninstall( _remove_step( "Removing Windows installer artifacts (PortableGit, Node, gateway-service)...", lambda: remove_portable_tooling_windows(hermes_home), "Removed {}", - "No Windows installer artifacts to remove", - ) + "No Windows installer artifacts to remove") # 5. Optionally remove ~/.hermes/ data directory (and named profiles) if full_uninstall: @@ -877,9 +856,7 @@ def main(argv=None) -> int: parser = argparse.ArgumentParser(prog="python -m hermes_cli.uninstall") parser.add_argument( - "--mode", - choices=["gui", "lite", "full"], - required=True, + "--mode", choices=["gui", "lite", "full"], required=True, help="gui = Chat GUI only; lite = GUI + agent, keep data; full = everything", ) args = _UninstallArgs(mode=parser.parse_args(argv).mode) diff --git a/hermes_cli/update_abort_recovery.py b/hermes_cli/update_abort_recovery.py index 243efe10ba..782537a720 100644 --- a/hermes_cli/update_abort_recovery.py +++ b/hermes_cli/update_abort_recovery.py @@ -63,8 +63,7 @@ def _surviving_pre_update_serve_runtimes(plan) -> list[dict]: live: dict[int, float | None] = { entry["pid"]: _numeric(entry.get("create_time")) for entry in ledger_entries() - if entry.get("purpose") in ("serve", "dashboard") and isinstance(entry.get("pid"), int) - } + if entry.get("purpose") in ("serve", "dashboard") and isinstance(entry.get("pid"), int)} except Exception as exc: logger.debug("Serve/dashboard survivor probe failed: %s", exc) live = None @@ -77,8 +76,7 @@ def _surviving_pre_update_serve_runtimes(plan) -> list[dict]: if ( planned_created is not None and live_created is not None - and abs(float(live_created) - float(planned_created)) >= 2.0 - ): + and abs(float(live_created) - float(planned_created)) >= 2.0): # Same number, different process: the pre-update runtime is gone # and something new registered under its PID. Not a survivor. continue @@ -116,8 +114,7 @@ def _recover_gateway_restart_after_abort( *, gateway_mode: bool, skip_profiles: set[str] | None = None, - skip_units: set[str] | None = None, -) -> dict[str, list]: + skip_units: set[str] | None = None) -> dict[str, list]: """Retry supervised gateway restarts from a clean Python process. ``hermes update`` normally performs the fleet restart in the interpreter that started before @@ -139,8 +136,7 @@ def _recover_gateway_restart_after_abort( "relaunch_attempted": relaunch_attempted, "failed": failed, "skipped": skipped, - "serve_units": dict(_empty_serve) if serve_units is None else serve_units, - } + "serve_units": dict(_empty_serve) if serve_units is None else serve_units} if not profiles and not recover_serve: return _result([], [], [], []) @@ -172,11 +168,7 @@ def _recover_gateway_restart_after_abort( "profiles": profiles, "supervisors": candidates, "serve_units": { - "recover": recover_serve, - "skip": _qualified_serve_skips(skip_units), - }, - } - ), + "recover": recover_serve, "skip": _qualified_serve_skips(skip_units)}}), "capture_output": True, "text": True, "encoding": "utf-8", @@ -186,13 +178,11 @@ def _recover_gateway_restart_after_abort( # Gateway profiles run sequentially at up to 90s each; the serve pass # adds its own restart + settle budget on top, so give the child room # for both rather than killing a recovery that was working. - "timeout": max(180, 30 + 90 * len(profiles) + (150 if recover_serve else 0)), - } + "timeout": max(180, 30 + 90 * len(profiles) + (150 if recover_serve else 0))} if sys.platform == "win32": kwargs["creationflags"] = ( getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) - | getattr(subprocess, "DETACHED_PROCESS", 0) - ) + | getattr(subprocess, "DETACHED_PROCESS", 0)) else: kwargs["start_new_session"] = True @@ -222,14 +212,9 @@ def _recover_gateway_restart_after_abort( and isinstance(raw_serve.get("verified"), list) and isinstance(raw_serve.get("failed"), list) and all( - isinstance(unit, str) - for unit in (*raw_serve["verified"], *raw_serve["failed"]) - ) - ): + isinstance(unit, str) for unit in (*raw_serve["verified"], *raw_serve["failed"]))): serve_units = { - "verified": sorted(raw_serve["verified"]), - "failed": sorted(raw_serve["failed"]), - } + "verified": sorted(raw_serve["verified"]), "failed": sorted(raw_serve["failed"])} elif recover_serve: # An unreadable serve block cannot be read as "nothing to do": the # units it describes are exactly the ones that host tui_gateway and @@ -245,8 +230,7 @@ def _recover_gateway_restart_after_abort( not all(isinstance(bucket, list) for bucket in buckets) or any(not isinstance(profile, str) for profile in reported) or set(reported) != set(profiles) - or len(reported) != len(set(reported)) - ): + or len(reported) != len(set(reported))): logger.warning("Fresh gateway restart recovery returned incomplete profiles") return _all_failed() @@ -256,8 +240,7 @@ def _recover_gateway_restart_after_abort( (relaunch_attempted, " ⚠ Relaunch attempted in a fresh process but not" " supervisor-verified (check these gateways manually): "), (serve_units["verified"], " ✓ Restarted serve unit(s) in a fresh process (new main PID observed): "), - (serve_units["failed"], " ⚠ Could not verify a replacement for serve unit(s): "), - ): + (serve_units["failed"], " ⚠ Could not verify a replacement for serve unit(s): ")): if names: print(text + ", ".join(names)) return _result(profiles, verified, relaunch_attempted, failed, serve_units) @@ -275,27 +258,20 @@ def _warn_stale_serve_runtimes(rows) -> None: return print( " ⚠ These serve/dashboard processes still run pre-update code" - " (they started before the checkout changed):" - ) + " (they started before the checkout changed):") for row in rows: supervisor = row.get("supervisor") or "unknown" print( f" pid {row.get('pid')} — {row.get('kind')}" - f" (profile {row.get('profile') or 'default'}, {supervisor})" - ) + f" (profile {row.get('profile') or 'default'}, {supervisor})") print( " Restart them before using Hermes again, e.g." " `systemctl --user restart hermes-serve.service`" - " or by relaunching `hermes serve` / the Desktop app." - ) + " or by relaunching `hermes serve` / the Desktop app.") def _abort_recovery_is_complete( - *, - planned_gateway_profiles, - covered_gateway_profiles, - recovery_result, - stale_runtime_rows, + *, planned_gateway_profiles, covered_gateway_profiles, recovery_result, stale_runtime_rows ) -> bool: """May a fresh-process recovery clear the incomplete flag? diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 28cd70cd81..218cdb93f5 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -27,8 +27,7 @@ from hermes_constants import get_default_hermes_root, venv_python_path from hermes_cli.update_abort_recovery import ( # noqa: F401 _abort_recovery_is_complete, _qualified_serve_skips, _recover_gateway_restart_after_abort, _serve_unit_recovery_available, _surviving_pre_update_serve_runtimes, - _warn_stale_serve_runtimes, -) + _warn_stale_serve_runtimes) # Re-exports from the split modules: every moved name stays reachable (and monkeypatchable) # as ``hermes_cli.update_cmd.``; the split modules import origin-internal names lazily. from hermes_cli.update_cmd_windows import ( # noqa: F401 @@ -45,8 +44,7 @@ from hermes_cli.update_cmd_windows import ( # noqa: F401 _resume_windows_gateways_and_merge_outcome, _self_and_non_gateway_ancestor_pids, _serve_relaunch_commands, _start_windows_gateway_service, _stop_process_trees, _stop_windows_gateway_service, _venv_launcher_ancestors, - _wait_for_windows_update_gateway_exit, _write_update_planned_stop_marker, -) + _wait_for_windows_update_gateway_exit, _write_update_planned_stop_marker) from hermes_cli.update_cmd_fleet import ( # noqa: F401 _FLEET_RESTART_PENDING_NAME, _FRESH_RESTART_SUPERVISORS, _GatewayRestartOutcome, _apply_pending_fleet_restart_catchup, _clear_fleet_restart_pending_marker, @@ -63,25 +61,21 @@ from hermes_cli.update_cmd_fleet import ( # noqa: F401 _wait_for_service_active, _warn_gateway_restart_phase_aborted, _warn_incomplete_gateway_fleet_restart, _warn_pending_fleet_restart, _warn_pending_fleet_restart_on_startup, _write_fleet_restart_pending_marker, - _write_gateway_update_exit_code, -) + _write_gateway_update_exit_code) from hermes_cli.update_cmd_zip import ( # noqa: F401 _ZIP_PRESERVED_TOP_LEVEL, _ZIP_STAGING_ARTIFACT_SUFFIXES, _abort_zip_update_if_dirty_tree, _atomic_replace_dir, _commit_staged_replacements, _discard_staged, _is_zip_preserved_entry_status_line, _is_zip_staging_artifact_status_line, _stage_replacement, - _update_via_zip, _zip_overlay_block_reason, -) + _update_via_zip, _zip_overlay_block_reason) from hermes_cli.update_cmd_stash import ( # noqa: F401 _AUTOSTASH_NAME_PREFIX, _AUTOSTASH_WARN_AGE_DAYS, _discard_stashed_changes, _git_untracked_paths, _park_stashed_changes, _print_stash_cleanup_guidance, _reject_unsafe_stash_restore, _resolve_stash_selector, _restore_stashed_changes, _restored_python_paths, _stash_apply_failed_only_on_existing_untracked, - _stash_local_changes_if_needed, _warn_orphaned_update_autostashes, -) + _stash_local_changes_if_needed, _warn_orphaned_update_autostashes) from hermes_cli.update_cmd_config import ( # noqa: F401 _LAST_SIBLING_SNAPSHOTS, _check_and_apply_config_migration, _migrate_sibling_profile_configs, - _print_items, _reload_config_modules, _run_config_check_fresh, _run_migrate_config_fresh, -) + _print_items, _reload_config_modules, _run_config_check_fresh, _run_migrate_config_fresh) from hermes_cli.update_cmd_deps import ( # noqa: F401 _INSTALL_DEFINING_FILES, _SELF_LOCKING_NATIVE_MODULES, _UPDATE_CRITICAL_MODULES, _abort_dependency_sync_if_self_locked, _capture_active_lazy_features, @@ -96,8 +90,7 @@ from hermes_cli.update_cmd_deps import ( # noqa: F401 _sync_python_dependencies_after_pull, _update_node_dependencies, _upgrade_pip_before_lazy_refresh, _validate_critical_modules_import, _venv_core_imports_healthy, _venv_foreign_owned_paths, _web_build_toolchain_ready, - _web_toolchain_roots, -) + _web_toolchain_roots) from hermes_cli.update_cmd_git import ( # noqa: F401 OFFICIAL_REPO_URL, OFFICIAL_REPO_URLS, SKIP_UPSTREAM_PROMPT_FILE, _ORPHAN_RESCUE_REFS_TO_KEEP, _ORPHAN_RESCUE_REF_MAX_AGE_DAYS, _add_upstream_remote, _assess_parked_branch_switch, @@ -107,8 +100,7 @@ from hermes_cli.update_cmd_git import ( # noqa: F401 _normalize_managed_eol, _portable_git_candidates, _print_fetch_failure, _print_parked_branch_kept_notice, _print_parked_branch_skip_warning, _prune_orphan_rescue_refs, _should_skip_upstream_prompt, _sync_fork_with_upstream, - _sync_with_upstream_if_needed, -) + _sync_with_upstream_if_needed) from hermes_cli.update_cmd_maint import ( # noqa: F401 _PRE_UPDATE_SNAPSHOT_KEEP, _PRE_UPDATE_SNAPSHOT_MAX_FILE_SIZE, _STALE_PURGE_PREFIXES, _STALE_PURGE_PROTECTED, _UPDATE_RUNTIME_RELOAD_MODULES, _clear_stale_sqlite_sidecars, @@ -121,8 +113,7 @@ from hermes_cli.update_cmd_maint import ( # noqa: F401 _resolve_pre_update_backup_mode, _restore_state_db_from_snapshot, _run_post_update_maintenance, _run_pre_update_backup, _sweep_bytecode_after_update, _update_complete_message, _verify_and_restore_one_state_db, - _verify_and_restore_state_dbs_post_update, -) + _verify_and_restore_state_dbs_post_update) logger = logging.getLogger(__name__) @@ -146,14 +137,8 @@ def _no_prompt_git_kwargs() -> dict: # CLI-startup imports (+ web_server.py, the desktop backend a fresh Windows install # launches) that must parse post-update; the syntax guard auto-rolls-back on failure. _UPDATE_CRITICAL_FILES = ( - "hermes_cli/main.py", - "hermes_cli/config.py", - "hermes_cli/__init__.py", - "hermes_cli/web_server.py", - "cli.py", - "run_agent.py", - "model_tools.py", - "toolsets.py", + "hermes_cli/main.py", "hermes_cli/config.py", "hermes_cli/__init__.py", + "hermes_cli/web_server.py", "cli.py", "run_agent.py", "model_tools.py", "toolsets.py", "hermes_constants.py", ) @@ -170,11 +155,8 @@ def _git_run(git_cmd, args, cwd=None, *, check=False, network=False): """Run git capturing utf-8 text (default cwd: checkout); ``network=True`` disables the terminal prompt so an HTTP 401 fails fast instead of hanging.""" return subprocess.run( - git_cmd + args, - cwd=_m().PROJECT_ROOT if cwd is None else cwd, - capture_output=True, - text=True, encoding="utf-8", errors="replace", - check=check, + git_cmd + args, cwd=_m().PROJECT_ROOT if cwd is None else cwd, capture_output=True, + text=True, encoding="utf-8", errors="replace", check=check, **(_no_prompt_git_kwargs() if network else {}), ) @@ -189,8 +171,7 @@ def _capture_head_sha(git_cmd, cwd) -> str | None: def _validate_python_files_syntax( - root, relpaths -) -> tuple[bool, str | None, str | None]: + root, relpaths) -> tuple[bool, str | None, str | None]: """Compile *relpaths* under *root* without writing bytecode into the tree.""" import py_compile import tempfile @@ -280,8 +261,7 @@ def _called_process_error_is_git(exc: subprocess.CalledProcessError) -> bool: def _called_process_error_is_python_dep_install( - exc: subprocess.CalledProcessError, -) -> bool: + exc: subprocess.CalledProcessError) -> bool: """True when the failed subprocess was a uv/pip (or ensurepip) install.""" parts = [part.lower() for part in _called_process_error_cmd_parts(exc)] if not parts: @@ -290,8 +270,7 @@ def _called_process_error_is_python_dep_install( if "ensurepip" in parts: return True if "install" in parts and ( - "pip" in parts or exe in {"pip", "pip.exe", "pip3", "pip3.exe", "uv", "uv.exe"} - ): + "pip" in parts or exe in {"pip", "pip.exe", "pip3", "pip3.exe", "uv", "uv.exe"}): return True return False @@ -346,13 +325,11 @@ def _should_zip_fallback_on_update_error(exc: BaseException) -> bool: return ( isinstance(exc, subprocess.CalledProcessError) and _m()._is_windows() - and _called_process_error_is_git(exc) - ) + and _called_process_error_is_git(exc)) def _print_called_process_error_tail( - exc: subprocess.CalledProcessError, *, limit: int = 12 -) -> None: + exc: subprocess.CalledProcessError, *, limit: int = 12) -> None: """Print a captured stderr/stdout tail when the failing call recorded one.""" blob = exc.stderr or exc.stdout or "" if isinstance(blob, bytes): @@ -405,8 +382,7 @@ def _write_lazy_refresh_incomplete_marker() -> None: def _format_concurrent_instances_message( - matches: list[tuple[int, str]], scripts_dir: Path -) -> str: + matches: list[tuple[int, str]], scripts_dir: Path) -> str: """Build a human-readable explanation + remediation hint for the user.""" shim = scripts_dir / "hermes.exe" lines = ["✗ Another hermes.exe is running:"] @@ -457,8 +433,7 @@ def _classify_concurrent_instance(pid: int) -> str: def _filter_non_gateway_concurrent_instances( - matches: list[tuple[int, str]], -) -> list[tuple[int, str]]: + matches: list[tuple[int, str]]) -> list[tuple[int, str]]: """Drop gateway matches (the pause + post-update restart machinery handles them); anything else (TUI, Desktop backend child, another REPL) has no pause path, so the gate aborts.""" non_gateway: list[tuple[int, str]] = [] @@ -486,15 +461,8 @@ def _run_logged_subprocess(cmd, *, cwd=None, env=None): """Run ``cmd`` with combined output captured into update.log only; returns the ``CompletedProcess`` so the caller can surface the output on failure.""" result = subprocess.run( - cmd, - cwd=cwd, - env=env, - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - encoding="utf-8", - errors="replace", + cmd, cwd=cwd, env=env, check=False, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, encoding="utf-8", errors="replace", ) _log_only_write(result.stdout or "") return result @@ -507,9 +475,7 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False): # Same marker-first admission gate as the apply path, so --check never reports git # state for an install whose real update mechanism is an image pull. from hermes_cli.update_contract import ( - evaluate_update_admission, - record_refusal_receipt, - ) + evaluate_update_admission, record_refusal_receipt) refusal = evaluate_update_admission(_m().PROJECT_ROOT) if refusal is not None: @@ -543,9 +509,7 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False): # checkouts are shallow: a plain fetch would unshallow them and rev-list would report a # bogus huge "behind" count, so fetch --depth 1 and report presence-only. is_shallow = ( - _git_run(git_cmd, ["rev-parse", "--is-shallow-repository"]).stdout.strip() - == "true" - ) + _git_run(git_cmd, ["rev-parse", "--is-shallow-repository"]).stdout.strip() == "true") depth_args = ["--depth", "1"] if is_shallow else [] if branch == "main": @@ -622,8 +586,7 @@ def _repair_current_checkout( active_lazy_features, active_tool_dependencies, upstream_checked, - _windows_gateway_resume, -) -> bool: + _windows_gateway_resume) -> bool: """Already-up-to-date path: keep the managed runtime current, repair a broken venv. Returns whether the checkout can be reported complete.""" # "No new commits" != safe interpreter: uv can keep the same CPython patch while @@ -657,10 +620,7 @@ def _repair_current_checkout( repair_uv = ensure_uv() # Venv gone entirely (repair interrupted after the old one was moved aside): recreate. venv_python_missing = not ( - venv_python_path( - _m().PROJECT_ROOT / "venv", windows=_m()._is_windows() - ) - ).exists() + venv_python_path(_m().PROJECT_ROOT / "venv", windows=_m()._is_windows())).exists() if venv_python_missing and repair_uv: print("→ Recreating virtual environment...") subprocess.run([repair_uv, "venv", "venv"], cwd=_m().PROJECT_ROOT, check=False) @@ -671,30 +631,18 @@ def _repair_current_checkout( repair_env = managed_python_env() repair_env["VIRTUAL_ENV"] = str(_m().PROJECT_ROOT / "venv") _m()._install_python_dependencies_with_optional_fallback( - [repair_uv, "pip"], env=repair_env, group="all" - ) + [repair_uv, "pip"], env=repair_env, group="all") _m()._refresh_active_lazy_features( - [repair_uv, "pip"], - env=repair_env, - features=active_lazy_features, - ) + [repair_uv, "pip"], env=repair_env, features=active_lazy_features) _m()._restore_active_tool_dependencies( - active_tool_dependencies, - [repair_uv, "pip"], - env=repair_env, - ) + active_tool_dependencies, [repair_uv, "pip"], env=repair_env) else: _m()._install_python_dependencies_with_optional_fallback( - [sys.executable, "-m", "pip"], group="all" - ) + [sys.executable, "-m", "pip"], group="all") _m()._refresh_active_lazy_features( - [sys.executable, "-m", "pip"], - features=active_lazy_features, - ) + [sys.executable, "-m", "pip"], features=active_lazy_features) _m()._restore_active_tool_dependencies( - active_tool_dependencies, - [sys.executable, "-m", "pip"], - ) + active_tool_dependencies, [sys.executable, "-m", "pip"]) _m()._clear_update_incomplete_marker() healthy_after, detail_after = _venv_core_imports_healthy() if healthy_after: @@ -702,20 +650,16 @@ def _repair_current_checkout( _check_and_apply_config_migration( assume_yes=assume_yes, gateway_mode=gateway_mode, - pre_update_snapshot_id=pre_update_snapshot_id, - ) + pre_update_snapshot_id=pre_update_snapshot_id) # The hand-off child never reaches the commits-pulled rebuild; do it here. if _rebuild_desktop_after_update( - desktop_dir, - had_desktop_app_before_update=had_desktop_app_before_update, - ): + desktop_dir, had_desktop_app_before_update=had_desktop_app_before_update): current_checkout_complete = _print_verified_update_completion("✓ Update complete!") else: current_checkout_complete = False _print_update_completion( "⚠ Update partially complete — the desktop app was " - "not rebuilt and is still on the previous build." - ) + "not rebuilt and is still on the previous build.") else: current_checkout_complete = False print(f"⚠ Venv still unhealthy after repair: {detail_after}") @@ -729,17 +673,14 @@ def _repair_current_checkout( completion_message=( "✓ Already up to date!" if upstream_checked - else "✓ Up to date with your fork (official repo not checked)." - ), - had_desktop_app_before_update=had_desktop_app_before_update, - ) + else "✓ Up to date with your fork (official repo not checked)."), + had_desktop_app_before_update=had_desktop_app_before_update) if runtime_repaired is not None and not _m()._is_windows(): print() print("⚠ Restart required to finish the managed Python runtime repair.") print( " Any running Hermes gateways, Desktop backends, or other " - "long-lived processes still use the previous runtime." - ) + "long-lived processes still use the previous runtime.") print(" Restart each of them to pick up the repaired runtime.") return current_checkout_complete @@ -753,32 +694,25 @@ def _reconcile_diverged_checkout(git_cmd, branch: str, pre_pull_sha) -> None: if _cur_branch and _cur_branch != branch: print( f" ⚠ Checkout is on custom branch '{_cur_branch}' — " - f"merging origin/{branch} instead of resetting so local commits survive..." - ) + f"merging origin/{branch} instead of resetting so local commits survive...") # Best-effort safety tag as a recovery anchor. subprocess.run( git_cmd + ["tag", f"pre-update-{_time.strftime('%Y%m%d-%H%M%S')}"], cwd=_m().PROJECT_ROOT, capture_output=True, - check=False, - ) + check=False) merge_result = _git_run(git_cmd, ["merge", "--no-edit", f"origin/{branch}"]) if merge_result.returncode != 0: subprocess.run( - git_cmd + ["merge", "--abort"], - cwd=_m().PROJECT_ROOT, - capture_output=True, + git_cmd + ["merge", "--abort"], cwd=_m().PROJECT_ROOT, capture_output=True, check=False, ) print( "✗ Merge conflict between local commits and upstream — " - "update stopped, nothing was changed." - ) + "update stopped, nothing was changed.") print( - f" Resolve manually: cd {_m().PROJECT_ROOT} && " - f"git merge origin/{branch}" - ) + f" Resolve manually: cd {_m().PROJECT_ROOT} && " f"git merge origin/{branch}") print(" Then re-run the update. Local work is untouched.") sys.exit(1) else: @@ -787,9 +721,7 @@ def _reconcile_diverged_checkout(git_cmd, branch: str, pre_pull_sha) -> None: # lose the whole local graph, so park pre_pull_sha behind a rescue ref first. merge_base_result = _git_run(git_cmd, ["merge-base", "HEAD", f"origin/{branch}"]) has_common_ancestor = bool( - merge_base_result.returncode == 0 - and merge_base_result.stdout.strip() - ) + merge_base_result.returncode == 0 and merge_base_result.stdout.strip()) if not has_common_ancestor and pre_pull_sha: from datetime import datetime as _dt, timezone @@ -797,8 +729,7 @@ def _reconcile_diverged_checkout(git_cmd, branch: str, pre_pull_sha) -> None: rescue_ref = ( f"refs/hermes-update-backups/orphan-{branch}-" f"{_dt.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}" - f"-{pre_pull_sha[:12]}" - ) + f"-{pre_pull_sha[:12]}") update_ref_result = _git_run(git_cmd, ["update-ref", rescue_ref, pre_pull_sha]) if update_ref_result.returncode == 0: print( @@ -806,8 +737,7 @@ def _reconcile_diverged_checkout(git_cmd, branch: str, pre_pull_sha) -> None: f"origin/{branch} (orphan divergence) — backed up " f"current HEAD to {rescue_ref} before resetting. " f"This backup expires after " - f"{_ORPHAN_RESCUE_REF_MAX_AGE_DAYS} days." - ) + f"{_ORPHAN_RESCUE_REF_MAX_AGE_DAYS} days.") else: # update-ref failure is intentionally non-fatal, but never claim a backup exists. print( @@ -815,12 +745,9 @@ def _reconcile_diverged_checkout(git_cmd, branch: str, pre_pull_sha) -> None: f"origin/{branch} (orphan divergence) — attempted " f"to back up current HEAD to {rescue_ref} before " "resetting, but the backup write failed " - f"(pre-reset SHA was {pre_pull_sha})." - ) + f"(pre-reset SHA was {pre_pull_sha}).") _prune_orphan_rescue_refs(git_cmd, _m().PROJECT_ROOT, branch) - print( - " ⚠ Fast-forward not possible (history diverged), resetting to match remote..." - ) + print(" ⚠ Fast-forward not possible (history diverged), resetting to match remote...") reset_result = _git_run(git_cmd, ["reset", "--hard", f"origin/{branch}"]) if reset_result.returncode != 0: print(f"✗ Failed to reset to origin/{branch}.") @@ -870,8 +797,7 @@ def _pull_updates( prompt_for_restore, gw_input_fn, discard_local_changes, - keep_stash, -): + keep_stash): """Fast-forward onto ``origin/`` and settle the autostash. Divergence by shape: custom branch -> merge, same branch -> reset, orphan history -> rescue ref first; a post-pull syntax error in a critical file rolls back. Exits on failure; returns pre-pull SHA.""" @@ -906,8 +832,7 @@ def _pull_updates( _m().PROJECT_ROOT, auto_stash_ref, prompt_user=prompt_for_restore, - input_fn=gw_input_fn, - ) + input_fn=gw_input_fn) return pre_pull_sha @@ -944,21 +869,14 @@ def _apply_parked_branch_guard( in_place_update = False if current_branch != branch and current_branch != "HEAD": switch_safe, switch_block_reason = _m()._assess_parked_branch_switch( - git_cmd, _m().PROJECT_ROOT, current_branch, branch - ) + git_cmd, _m().PROJECT_ROOT, current_branch, branch) if not switch_safe: _m()._print_parked_branch_skip_warning( - git_cmd, - _m().PROJECT_ROOT, - current_branch, - branch, - switch_block_reason, - ) + git_cmd, _m().PROJECT_ROOT, current_branch, branch, switch_block_reason) print() print( "⚠ Update finished — code update SKIPPED" - f"{_branch_head_suffix(git_cmd, _m().PROJECT_ROOT)}" - ) + f"{_branch_head_suffix(git_cmd, _m().PROJECT_ROOT)}") _m()._resume_windows_gateways_after_update(_windows_gateway_resume) sys.exit(1) if switch_block_reason.startswith("unmerged:"): @@ -970,8 +888,7 @@ def _apply_parked_branch_guard( _in_place_configured = ( isinstance(_upd_cfg, dict) and _upd_cfg.get("parked_branch_strategy", "switch") - == "update_in_place" - ) + == "update_in_place") if _in_place_configured and not switch_branch: # --branch typos used to surface via the checkout failing, which this path skips. verify_ref = _git_run(git_cmd, ["rev-parse", "--verify", "--quiet", f"origin/{branch}"]) @@ -981,21 +898,16 @@ def _apply_parked_branch_guard( in_place_update = True print( f" ℹ On branch '{current_branch}' — updating it in place from " - f"origin/{branch} (no branch switch; local commits preserved)." - ) + f"origin/{branch} (no branch switch; local commits preserved).") else: parked_branch_switched = True _m()._print_parked_branch_kept_notice( - current_branch, - branch, - switch_block_reason.split(":", 1)[1], - ) + current_branch, branch, switch_block_reason.split(":", 1)[1]) else: parked_branch_switched = True print( f" ⚠ Checkout was parked on '{current_branch}' " - f"(fully merged) — switching back to {branch}..." - ) + f"(fully merged) — switching back to {branch}...") return parked_branch_switched, in_place_update, switch_block_reason @@ -1009,15 +921,13 @@ def _prepare_checkout_for_update( gateway_mode, gw_input_fn, switch_branch, - _windows_gateway_resume, -): + _windows_gateway_resume): """Parked-branch guard, land on the target, stash, count new commits. Exits when the checkout is unsafe to move or the target is missing. ``commit_count`` is 0 when up to date, -1 when tips differ but the shallow count is unrecoverable.""" parked_branch_switched, in_place_update, switch_block_reason = _apply_parked_branch_guard( git_cmd, branch, current_branch, switch_branch=switch_branch, - _windows_gateway_resume=_windows_gateway_resume, - ) + _windows_gateway_resume=_windows_gateway_resume) if not in_place_update and current_branch != branch: if current_branch == "HEAD": @@ -1034,8 +944,7 @@ def _prepare_checkout_for_update( _m().PROJECT_ROOT, auto_stash_ref, prompt_user=False, - input_fn=gw_input_fn, - ) + input_fn=gw_input_fn) print(f"✗ Branch '{branch}' does not exist locally or on origin.") if track_result.stderr.strip(): print(f" {track_result.stderr.strip().splitlines()[0]}") @@ -1046,8 +955,7 @@ def _prepare_checkout_for_update( prompt_for_restore = ( auto_stash_ref is not None and not assume_yes - and (gateway_mode or (sys.stdin.isatty() and sys.stdout.isatty())) - ) + and (gateway_mode or (sys.stdin.isatty() and sys.stdout.isatty()))) # On shallow checkouts `rev-list --count` can report the entire remote ancestry. The # zero/nonzero gate is still sound; treat the shallow NUMBER as unknown and recover it @@ -1056,9 +964,7 @@ def _prepare_checkout_for_update( commit_count = int(result.stdout.strip()) apply_is_shallow = ( - _git_run(git_cmd, ["rev-parse", "--is-shallow-repository"]).stdout.strip() - == "true" - ) + _git_run(git_cmd, ["rev-parse", "--is-shallow-repository"]).stdout.strip() == "true") if commit_count > 0 and apply_is_shallow: from hermes_cli.banner import _github_compare_behind @@ -1075,30 +981,18 @@ def _prepare_checkout_for_update( if commit_count == 0 and is_fork and branch == "main": pre_sync_sha = _capture_head_sha(git_cmd, _m().PROJECT_ROOT) upstream_checked = _m()._sync_with_upstream_if_needed( - git_cmd, - _m().PROJECT_ROOT, - assume_yes=assume_yes, - input_fn=gw_input_fn, - ) + git_cmd, _m().PROJECT_ROOT, assume_yes=assume_yes, input_fn=gw_input_fn) post_sync_sha = _capture_head_sha(git_cmd, _m().PROJECT_ROOT) if pre_sync_sha and post_sync_sha and pre_sync_sha != post_sync_sha: synced_count = _count_commits_between( - git_cmd, - _m().PROJECT_ROOT, - pre_sync_sha, - post_sync_sha, - ) + git_cmd, _m().PROJECT_ROOT, pre_sync_sha, post_sync_sha) # HEAD moving is proof of an update even if the count can't be read. commit_count = max(1, synced_count) return _CheckoutPlan( - auto_stash_ref=auto_stash_ref, - commit_count=commit_count, - in_place_update=in_place_update, - parked_branch_switched=parked_branch_switched, - prompt_for_restore=prompt_for_restore, - switch_block_reason=switch_block_reason, - upstream_checked=upstream_checked, + auto_stash_ref=auto_stash_ref, commit_count=commit_count, in_place_update=in_place_update, + parked_branch_switched=parked_branch_switched, prompt_for_restore=prompt_for_restore, + switch_block_reason=switch_block_reason, upstream_checked=upstream_checked, ) @@ -1126,10 +1020,7 @@ def _resolve_update_options(args, gateway_mode: bool) -> _UpdateOptions: # Captured before any pull so the completion line can report the transition. pre_update_version = _read_project_version() gw_input_fn = ( - (lambda prompt, default="": _gateway_prompt(prompt, default)) - if gateway_mode - else None - ) + (lambda prompt, default="": _gateway_prompt(prompt, default)) if gateway_mode else None) assume_yes = bool(getattr(args, "yes", False)) # --keep-stash (desktop updater): never re-apply the autostash; only when an update # landed — abort/no-op paths still restore since the tree is unchanged. @@ -1141,10 +1032,7 @@ def _resolve_update_options(args, gateway_mode: bool) -> _UpdateOptions: # Interactive terminals always stash-and-ask; only non-interactive updates consult # updates.non_interactive_local_changes (auto-restore vs discard). _non_interactive_update = ( - gateway_mode - or assume_yes - or not (sys.stdin.isatty() and sys.stdout.isatty()) - ) + gateway_mode or assume_yes or not (sys.stdin.isatty() and sys.stdout.isatty())) discard_local_changes = False if _non_interactive_update: try: @@ -1160,13 +1048,9 @@ def _resolve_update_options(args, gateway_mode: bool) -> _UpdateOptions: discard_local_changes = False return _UpdateOptions( active_lazy_features=active_lazy_features, - active_tool_dependencies=active_tool_dependencies, - pre_update_version=pre_update_version, - gw_input_fn=gw_input_fn, - assume_yes=assume_yes, - keep_stash=keep_stash, - switch_branch=switch_branch, - discard_local_changes=discard_local_changes, + active_tool_dependencies=active_tool_dependencies, pre_update_version=pre_update_version, + gw_input_fn=gw_input_fn, assume_yes=assume_yes, keep_stash=keep_stash, + switch_branch=switch_branch, discard_local_changes=discard_local_changes, ) @@ -1185,9 +1069,7 @@ def _begin_update_receipt_and_plan(args): _pre_update_plan = None with _best_effort('Update plan phase failed: %s'): from hermes_cli.update_inventory import ( - collect_runtime_inventory, - record_plan_in_receipt, - ) + collect_runtime_inventory, record_plan_in_receipt) _pre_update_plan = collect_runtime_inventory() record_plan_in_receipt(_pre_update_plan) @@ -1234,12 +1116,10 @@ def _prepare_git_command() -> tuple[bool, list, bool]: "windows.appendAtomically=false", "config", "windows.appendAtomically", - "false", - ], + "false"], cwd=_m().PROJECT_ROOT, check=False, - capture_output=True, - ) + capture_output=True) git_cmd = ["git"] if sys.platform == "win32": @@ -1275,12 +1155,10 @@ def _verify_head_after_pull( print("✗ Code did not move — update was a no-op.") print( f" HEAD is pinned to {pre_pull_sha[:10]} (detached checkout); " - f"origin/{branch} advanced but the working tree stayed put." - ) + f"origin/{branch} advanced but the working tree stayed put.") print( " Reattach to the branch and retry: " - f"git -C {_m().PROJECT_ROOT} checkout {branch} && hermes update" - ) + f"git -C {_m().PROJECT_ROOT} checkout {branch} && hermes update") _m()._resume_windows_gateways_after_update(_windows_gateway_resume) sys.exit(1) @@ -1288,27 +1166,21 @@ def _verify_head_after_pull( # legitimate exception (origin/ merged INTO the checked-out branch). post_pull_branch = _git_run(git_cmd, ["rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip() if ( - not in_place_update - and post_pull_branch - and post_pull_branch not in {branch, "HEAD"} - ): + not in_place_update and post_pull_branch and post_pull_branch not in {branch, "HEAD"}): print() print( f"✗ Update pulled origin/{branch}, but the checkout is on " - f"'{post_pull_branch}' — not claiming success." - ) + f"'{post_pull_branch}' — not claiming success.") print( " Switch to the target branch and retry: " - f"git -C {_m().PROJECT_ROOT} checkout {branch} && hermes update" - ) + f"git -C {_m().PROJECT_ROOT} checkout {branch} && hermes update") _m()._resume_windows_gateways_after_update(_windows_gateway_resume) sys.exit(1) return post_pull_sha def _handle_update_called_process_error( - e, args, gateway_mode: bool, had_desktop_app_before_update: bool -) -> None: + e, args, gateway_mode: bool, had_desktop_app_before_update: bool) -> None: """Git/installer failure: ZIP-fallback when safe, else report and ``sys.exit(1)``.""" stage = _format_update_failure_stage(e) if _should_zip_fallback_on_update_error(e): @@ -1316,9 +1188,7 @@ def _handle_update_called_process_error( print("→ Falling back to ZIP download...") print() desktop_build_ok = _update_via_zip( - args, - had_desktop_app_before_update=had_desktop_app_before_update, - ) + args, had_desktop_app_before_update=had_desktop_app_before_update) if gateway_mode: _write_gateway_update_exit_code(desktop_build_ok) else: @@ -1328,14 +1198,12 @@ def _handle_update_called_process_error( print( " The git update already finished. Re-downloading the source " "ZIP cannot fix a dependency install error and would overwrite " - "local files." - ) + "local files.") if _m()._is_windows(): print(" Retry through the venv interpreter:") print( ' venv\\Scripts\\python.exe -c ' - '"from hermes_cli.main import main; main()" update --yes' - ) + '"from hermes_cli.main import main; main()" update --yes') with suppress(Exception): from hermes_cli.update_receipt import finalize_update_receipt @@ -1357,8 +1225,7 @@ def _finish_already_up_to_date( had_desktop_app_before_update: bool, active_lazy_features, active_tool_dependencies, - _windows_gateway_resume, -) -> None: + _windows_gateway_resume) -> None: """"Already up to date" path: restore stash/branch, repair the checkout, catch up the fleet. ``sys.exit(1)`` when the repair is incomplete (after gateway exit code + partial receipt).""" auto_stash_ref = _plan.auto_stash_ref @@ -1376,33 +1243,27 @@ def _finish_already_up_to_date( _m().PROJECT_ROOT, auto_stash_ref, prompt_user=prompt_for_restore, - input_fn=gw_input_fn, - ) + input_fn=gw_input_fn) if parked_branch_switched: if switch_block_reason.startswith("unmerged:"): _count = switch_block_reason.split(":", 1)[1] print( f" ✓ Checkout was parked on '{current_branch}' — " f"switched back to {branch}; {_count} unmerged " - f"commit(s) kept on '{current_branch}'." - ) + f"commit(s) kept on '{current_branch}'.") else: print( f" ✓ Checkout was parked on '{current_branch}' (fully " - f"merged) — switched back to {branch}." - ) + f"merged) — switched back to {branch}.") elif current_branch not in {branch, "HEAD"}: _git_run(git_cmd, ["checkout", current_branch]) current_checkout_complete = _repair_current_checkout( - assume_yes=assume_yes, - gateway_mode=gateway_mode, - pre_update_snapshot_id=pre_update_snapshot_id, - desktop_dir=desktop_dir, + assume_yes=assume_yes, gateway_mode=gateway_mode, + pre_update_snapshot_id=pre_update_snapshot_id, desktop_dir=desktop_dir, had_desktop_app_before_update=had_desktop_app_before_update, active_lazy_features=active_lazy_features, - active_tool_dependencies=active_tool_dependencies, - upstream_checked=upstream_checked, + active_tool_dependencies=active_tool_dependencies, upstream_checked=upstream_checked, _windows_gateway_resume=_windows_gateway_resume, ) _m()._resume_windows_gateways_after_update(_windows_gateway_resume) @@ -1443,8 +1304,7 @@ def _cmd_update_impl(args, gateway_mode: bool): _record_update_step( "pre_update_backup", pre_update_snapshot_id is not None, - f"snapshot={pre_update_snapshot_id}" if pre_update_snapshot_id else "disabled or failed", - ) + f"snapshot={pre_update_snapshot_id}" if pre_update_snapshot_id else "disabled or failed") _windows_gateway_resume = _m()._pause_windows_gateways_for_update() if _windows_gateway_resume: @@ -1472,9 +1332,7 @@ def _cmd_update_impl(args, gateway_mode: bool): if use_zip_update: try: desktop_build_ok = _update_via_zip( - args, - had_desktop_app_before_update=had_desktop_app_before_update, - ) + args, had_desktop_app_before_update=had_desktop_app_before_update) finally: _m()._resume_windows_gateways_after_update(_windows_gateway_resume) if gateway_mode: @@ -1509,14 +1367,8 @@ def _cmd_update_impl(args, gateway_mode: bool): current_branch = result.stdout.strip() _plan = _prepare_checkout_for_update( - git_cmd, - branch, - current_branch, - is_fork=is_fork, - assume_yes=assume_yes, - gateway_mode=gateway_mode, - gw_input_fn=gw_input_fn, - switch_branch=switch_branch, + git_cmd, branch, current_branch, is_fork=is_fork, assume_yes=assume_yes, + gateway_mode=gateway_mode, gw_input_fn=gw_input_fn, switch_branch=switch_branch, _windows_gateway_resume=_windows_gateway_resume, ) auto_stash_ref = _plan.auto_stash_ref @@ -1526,15 +1378,9 @@ def _cmd_update_impl(args, gateway_mode: bool): if commit_count == 0: _finish_already_up_to_date( - git_cmd, - branch, - current_branch, - _plan, - assume_yes=assume_yes, - gateway_mode=gateway_mode, - gw_input_fn=gw_input_fn, - pre_update_snapshot_id=pre_update_snapshot_id, - desktop_dir=desktop_dir, + git_cmd, branch, current_branch, _plan, assume_yes=assume_yes, + gateway_mode=gateway_mode, gw_input_fn=gw_input_fn, + pre_update_snapshot_id=pre_update_snapshot_id, desktop_dir=desktop_dir, had_desktop_app_before_update=had_desktop_app_before_update, active_lazy_features=active_lazy_features, active_tool_dependencies=active_tool_dependencies, @@ -1550,22 +1396,15 @@ def _cmd_update_impl(args, gateway_mode: bool): print("→ Pulling updates...") pre_pull_sha = _pull_updates( - git_cmd, - branch, - auto_stash_ref, - prompt_for_restore=prompt_for_restore, - gw_input_fn=gw_input_fn, - discard_local_changes=discard_local_changes, + git_cmd, branch, auto_stash_ref, prompt_for_restore=prompt_for_restore, + gw_input_fn=gw_input_fn, discard_local_changes=discard_local_changes, keep_stash=keep_stash, ) _invalidate_update_cache() post_pull_sha = _verify_head_after_pull( - git_cmd, - branch, - pre_pull_sha, - in_place_update=in_place_update, + git_cmd, branch, pre_pull_sha, in_place_update=in_place_update, _windows_gateway_resume=_windows_gateway_resume, ) @@ -1579,19 +1418,12 @@ def _cmd_update_impl(args, gateway_mode: bool): if is_fork and branch == "main": _m()._sync_with_upstream_if_needed( - git_cmd, - _m().PROJECT_ROOT, - assume_yes=assume_yes, - input_fn=gw_input_fn, - ) + git_cmd, _m().PROJECT_ROOT, assume_yes=assume_yes, input_fn=gw_input_fn) # .[all], falling back to base + extras individually so one broken extra doesn't strip # the rest; the ownership preflight refuses first on foreign-owned (sudo-pip) venv files. _sync_python_dependencies_after_pull( - git_cmd, - branch, - pre_pull_sha, - active_lazy_features=active_lazy_features, + git_cmd, branch, pre_pull_sha, active_lazy_features=active_lazy_features, active_tool_dependencies=active_tool_dependencies, _windows_gateway_resume=_windows_gateway_resume, ) @@ -1600,20 +1432,16 @@ def _cmd_update_impl(args, gateway_mode: bool): _m()._build_web_ui(_m().PROJECT_ROOT / "web") desktop_build_ok = _rebuild_desktop_after_update( - desktop_dir, - had_desktop_app_before_update=had_desktop_app_before_update, - ) + desktop_dir, had_desktop_app_before_update=had_desktop_app_before_update) print() print(f"✓ Code updated!{_branch_head_suffix(git_cmd, _m().PROJECT_ROOT)}") update_complete = _run_post_update_maintenance( - assume_yes=assume_yes, - gateway_mode=gateway_mode, + assume_yes=assume_yes, gateway_mode=gateway_mode, pre_update_snapshot_id=pre_update_snapshot_id, had_desktop_app_before_update=had_desktop_app_before_update, - node_failures=node_failures, - desktop_build_ok=desktop_build_ok, + node_failures=node_failures, desktop_build_ok=desktop_build_ok, pre_update_version=pre_update_version, ) @@ -1626,10 +1454,8 @@ def _cmd_update_impl(args, gateway_mode: bool): _restart = _restart_gateway_fleet_after_update(_pre_update_plan, gateway_mode) _resume_windows_gateways_and_merge_outcome(_restart, _windows_gateway_resume, gateway_mode) _verify_fleet_after_update( - _restart, - _pre_update_plan=_pre_update_plan, - _windows_gateway_resume=_windows_gateway_resume, - node_failures=node_failures, + _restart, _pre_update_plan=_pre_update_plan, + _windows_gateway_resume=_windows_gateway_resume, node_failures=node_failures, update_complete=update_complete, ) diff --git a/hermes_cli/update_cmd_config.py b/hermes_cli/update_cmd_config.py index d4f5bb3273..ab27f88ac2 100644 --- a/hermes_cli/update_cmd_config.py +++ b/hermes_cli/update_cmd_config.py @@ -31,8 +31,7 @@ def _reload_config_modules() -> None: "hermes_cli.config", "hermes_cli.config_migrations", "hermes_cli._subprocess_compat", - "hermes_cli.dashboard_procs", - ): + "hermes_cli.dashboard_procs"): mod = sys.modules.get(mod_name) if mod is not None: try: @@ -74,10 +73,7 @@ def _migrate_sibling_profile_configs() -> list[tuple[str, int, int]]: migrated: list[tuple[str, int, int]] = [] with _best_effort('Sibling profile enumeration failed: %s'): from hermes_constants import ( - get_process_hermes_home, - reset_hermes_home_override, - set_hermes_home_override, - ) + get_process_hermes_home, reset_hermes_home_override, set_hermes_home_override) from hermes_cli.profiles import _get_profiles_root, _PROFILE_ID_RE active_home = get_process_hermes_home() @@ -124,8 +120,7 @@ def _restore_snapshot_safety_nets(pre_update_snapshot_id) -> None: print( " ⚠️ cron/jobs.json lost jobs during this update — " f"restored {cron_restore['job_count']} job(s) from " - f"pre-update snapshot {cron_restore['snapshot_id']}." - ) + f"pre-update snapshot {cron_restore['snapshot_id']}.") except Exception as exc: # Never let the cron safety net break an otherwise-good update. logger.debug("Cron jobs auto-restore check failed: %s", exc) @@ -141,8 +136,7 @@ def _restore_snapshot_safety_nets(pre_update_snapshot_id) -> None: print( " ⚠️ config.yaml user model settings were rewritten during " f"this update — restored {', '.join(cfg_restore['keys'])} " - f"from pre-update snapshot {cfg_restore['snapshot_id']}." - ) + f"from pre-update snapshot {cfg_restore['snapshot_id']}.") except Exception as exc: # Never let the config safety net break an otherwise-good update. logger.debug("Config model-settings auto-restore check failed: %s", exc) @@ -152,38 +146,33 @@ def _restore_snapshot_safety_nets(pre_update_snapshot_id) -> None: from hermes_cli.backup import restore_cron_jobs_all_profiles for _restored in restore_cron_jobs_all_profiles( - _LAST_SIBLING_SNAPSHOTS - ): + _LAST_SIBLING_SNAPSHOTS): print() print( f" ⚠️ Profile '{_restored['profile']}': cron/jobs.json " f"lost jobs during this update — restored " f"{_restored['job_count']} job(s) from pre-update " - f"snapshot {_restored['snapshot_id']}." - ) + f"snapshot {_restored['snapshot_id']}.") # Same config model-settings safety net for sibling profiles. with _best_effort('Sibling config auto-restore check failed: %s'): from hermes_cli.backup import restore_config_model_settings_all_profiles for _cfg_restored in restore_config_model_settings_all_profiles( - _LAST_SIBLING_SNAPSHOTS - ): + _LAST_SIBLING_SNAPSHOTS): print() print( f" ⚠️ Profile '{_cfg_restored['profile']}': config.yaml " f"user model settings were rewritten during this update — " f"restored {', '.join(_cfg_restored['keys'])} from " - f"pre-update snapshot {_cfg_restored['snapshot_id']}." - ) + f"pre-update snapshot {_cfg_restored['snapshot_id']}.") def _check_and_apply_config_migration( *, assume_yes: bool = False, gateway_mode: bool = False, - pre_update_snapshot_id: str | None = None, -) -> None: + pre_update_snapshot_id: str | None = None) -> None: """Check/apply config migrations on an update completion path. Must use freshly-reloaded modules (see ``_reload_config_modules``) and run on EVERY @@ -195,8 +184,7 @@ def _check_and_apply_config_migration( _migrate_sibling_profile_configs, _reload_config_modules, _run_config_check_fresh, - _run_migrate_config_fresh, - ) + _run_migrate_config_fresh) print() print("→ Checking configuration for new options...") @@ -204,9 +192,7 @@ def _check_and_apply_config_migration( _reload_config_modules() from hermes_cli.config import ( - get_missing_env_vars, - get_missing_config_fields, - ) + get_missing_env_vars, get_missing_config_fields) # A config-check failure must not break an otherwise-successful update. try: @@ -257,29 +243,22 @@ def _check_and_apply_config_migration( elif gateway_mode: response = ( _gateway_prompt( - "Would you like to configure new options now? [Y/n]", "n" - ) + "Would you like to configure new options now? [Y/n]", "n") .strip() - .lower() - ) + .lower()) elif not (sys.stdin.isatty() and sys.stdout.isatty()): print(" ℹ Non-interactive session — applying safe config migrations.") response = "auto" else: try: - response = ( - input("Would you like to configure them now? [Y/n]: ") - .strip() - .lower() - ) + response = (input("Would you like to configure them now? [Y/n]: ").strip().lower()) except EOFError: response = "n" except UnicodeDecodeError: # Non-UTF-8 locales / embedded terminals can make input() raise this. print( " ⚠ Could not read input (encoding issue). Skipping. " - "Run 'hermes config migrate' manually to configure." - ) + "Run 'hermes config migrate' manually to configure.") response = "n" if response in {"", "y", "yes", "auto"}: diff --git a/hermes_cli/update_cmd_deps.py b/hermes_cli/update_cmd_deps.py index f6d90a9acc..2c50bf6b03 100644 --- a/hermes_cli/update_cmd_deps.py +++ b/hermes_cli/update_cmd_deps.py @@ -19,7 +19,6 @@ from hermes_constants import venv_python_path # Log-record parity with the origin module. logger = logging.getLogger("hermes_cli.update_cmd") - # Files defining the editable install; a pull touching none of them cannot invalidate it. _INSTALL_DEFINING_FILES = "pyproject.toml", "setup.py", "setup.cfg", "MANIFEST.in", "uv.lock" @@ -42,8 +41,7 @@ def _editable_install_is_current(git_cmd, cwd, pre_pull_sha: str | None) -> bool + list(_INSTALL_DEFINING_FILES), cwd=cwd, capture_output=True, - text=True, encoding="utf-8", errors="replace", - ) + text=True, encoding="utf-8", errors="replace") except OSError: return False if result.returncode != 0: @@ -57,8 +55,7 @@ _UPDATE_CRITICAL_MODULES = "hermes_cli.main", "run_agent", "model_tools", "tools def _critical_module_import_failures( - root, *, report_runtime_errors: bool = False -) -> dict[str, tuple[str, str]]: + root, *, report_runtime_errors: bool = False) -> dict[str, tuple[str, str]]: """Import each ``_UPDATE_CRITICAL_MODULES`` entry in a subprocess; return failures in probe order. Syntax validation only *parses*, so a partially-updated tree (reachable via the Windows @@ -99,9 +96,7 @@ def _critical_module_import_failures( tuple(sorted(FIRST_PARTY_MODULE_ROOTS)), report_runtime_errors, report_runtime_errors, - marker, - ) - ) + marker)) try: interpreter = sys.executable with suppress(Exception): @@ -109,21 +104,13 @@ def _critical_module_import_failures( if venv_python.exists(): interpreter = str(venv_python) result = subprocess.run( - [interpreter, "-c", probe], - cwd=str(root), - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=120, + [interpreter, "-c", probe], cwd=str(root), capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=120, ) except subprocess.TimeoutExpired: return { "critical-module probe": ( - "TimeoutExpired", - "timed out before reporting import health", - ) - } + "TimeoutExpired", "timed out before reporting import health")} except (OSError, subprocess.SubprocessError): # Can't run the probe — don't block the update on our own tooling. return {} @@ -133,9 +120,7 @@ def _critical_module_import_failures( "critical-module probe": ( "ProbeTerminated", "terminated before reporting import health " - f"(exit code {result.returncode})", - ) - } + f"(exit code {result.returncode})")} try: import json @@ -144,22 +129,17 @@ def _critical_module_import_failures( not isinstance(item, list) or len(item) != 3 or not all(isinstance(value, str) for value in item) - for item in failures - ): + for item in failures): raise ValueError("invalid import-health payload") return {str(module): (str(kind), str(detail)) for module, kind, detail in failures} except (TypeError, ValueError): return { - "critical-module probe": ( - "MalformedPayload", - "reported malformed import health data", - ) + "critical-module probe": ( "MalformedPayload", "reported malformed import health data" ) } def _validate_critical_modules_import( - root, *, report_runtime_errors: bool = False -) -> tuple[bool, str | None, str | None]: + root, *, report_runtime_errors: bool = False) -> tuple[bool, str | None, str | None]: """Return the first critical-module import failure, if any.""" failures = _critical_module_import_failures(root, report_runtime_errors=report_runtime_errors) if failures: @@ -172,8 +152,7 @@ def _npm_bin_exists(bin_dir: Path, name: str) -> bool: """True when an npm bin shim for *name* exists (POSIX or Windows).""" return any( (bin_dir / candidate).exists() - for candidate in (name, f"{name}.cmd", f"{name}.ps1", f"{name}.exe") - ) + for candidate in (name, f"{name}.cmd", f"{name}.ps1", f"{name}.exe")) def _web_build_toolchain_ready(*roots: Path) -> bool: @@ -182,12 +161,9 @@ def _web_build_toolchain_ready(*roots: Path) -> bool: bin_dirs = [ bin_dir for bin_dir in (root / "node_modules" / ".bin" for root in roots) - if bin_dir.is_dir() - ] + if bin_dir.is_dir()] return bool(bin_dirs) and all( - any(_npm_bin_exists(bin_dir, tool) for bin_dir in bin_dirs) - for tool in ("tsc", "vite") - ) + any(_npm_bin_exists(bin_dir, tool) for bin_dir in bin_dirs) for tool in ("tsc", "vite")) def _web_toolchain_roots(web_dir: Path) -> tuple[Path, ...]: @@ -202,32 +178,22 @@ def _ensure_venv_pip(pip_cmd: list, python_exe: str) -> None: from hermes_cli.update_cmd import _m try: subprocess.run( - pip_cmd + ["--version"], - cwd=_m().PROJECT_ROOT, - check=True, - capture_output=True, - ) + pip_cmd + ["--version"], cwd=_m().PROJECT_ROOT, check=True, capture_output=True) except subprocess.CalledProcessError: subprocess.run( [python_exe, "-m", "ensurepip", "--upgrade", "--default-pip"], cwd=_m().PROJECT_ROOT, - check=True, - ) + check=True) def _upgrade_pip_before_lazy_refresh( - install_cmd_prefix: list[str], - *, - env: dict[str, str] | None = None, -) -> None: + install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> None: """Upgrade pip before lazy refreshes: older pip can fail setuptools source builds and leave a partially-written venv. Never raises.""" from hermes_cli.update_cmd import _m try: _m()._run_package_only_install( - install_cmd_prefix + ["install", "--upgrade", "pip"], - env=env, - ) + install_cmd_prefix + ["install", "--upgrade", "pip"], env=env) except subprocess.CalledProcessError as exc: logger.debug("pip upgrade before lazy refresh failed: %s", exc) @@ -255,10 +221,7 @@ def _capture_active_tool_dependencies() -> list[str]: def _restore_active_tool_dependencies( - dependencies: list[str], - install_cmd_prefix: list[str], - *, - env: dict[str, str] | None = None, + dependencies: list[str], install_cmd_prefix: list[str], *, env: dict[str, str] | None = None ) -> None: """Restore allowlisted ``hermes tools`` dependencies (from a pre-rebuild probe) into a rebuilt venv. Never raises: a failed optional tool must not block the update, but must be reported.""" @@ -287,12 +250,10 @@ def _restore_active_tool_dependencies( "-c", "import importlib.util,sys; " "raise SystemExit(0 if importlib.util.find_spec(sys.argv[1]) else 1)", - module_name, - ], + module_name], capture_output=True, env=env, - check=False, - ) + check=False) if probe.returncode == 0: continue except (subprocess.SubprocessError, OSError): @@ -310,9 +271,7 @@ def _restore_active_tool_dependencies( for name, install_args in missing: try: _m()._run_package_only_install( - install_cmd_prefix + ["install", *install_args, "--quiet"], - env=env, - ) + install_cmd_prefix + ["install", *install_args, "--quiet"], env=env) restored.append(name) except Exception as exc: # Best-effort: surface failures without aborting the update. @@ -330,8 +289,7 @@ def _refresh_active_lazy_features( install_cmd_prefix: list[str] | None = None, *, env: dict[str, str] | None = None, - features: list[str] | None = None, -) -> bool: + features: list[str] | None = None) -> bool: """Refresh previously-activated lazy backends (cold ones untouched) after a code update. The core install never touches ``lazy_deps`` backends, so a bumped :data:`LAZY_DEPS` pin @@ -464,10 +422,7 @@ def _is_android_python() -> bool: def _install_psutil_android_compat( - install_cmd_prefix: list[str], - *, - env: dict[str, str] | None = None, -) -> None: + install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> None: """Install psutil on Android by patching its platform detection: setup gates Linux sources on ``sys.platform.startswith('linux')`` but Termux reports ``'android'`` though the Linux path compiles fine. Only this attempt's build tree is patched. Stopgap until psutil PR 2762 ships.""" @@ -483,9 +438,7 @@ def _install_psutil_android_compat( src_root = prepare_patched_psutil_sdist(archive, tmp_path) _m()._run_install_with_heartbeat( - install_cmd_prefix + ["install", "--no-build-isolation", str(src_root)], - env=env, - ) + install_cmd_prefix + ["install", "--no-build-isolation", str(src_root)], env=env) def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None: @@ -509,8 +462,7 @@ def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None: result = subprocess.run( pip_cmd + ["install", "uv", "--only-binary", ":all:"], cwd=_m().PROJECT_ROOT, - check=False, - ) + check=False) if result.returncode != 0: return None return resolve_uv() or shutil.which("uv") @@ -566,8 +518,7 @@ def _npm_lockfile_changed(hermes_root: Path) -> bool: # Never skip when the web toolchain never landed, or later updates build on a half-installed tree. web_dir = _m().PROJECT_ROOT / "web" if (web_dir / "package.json").is_file() and not _web_build_toolchain_ready( - *_web_toolchain_roots(web_dir) - ): + *_web_toolchain_roots(web_dir)): return True try: # Key the cache by PROJECT_ROOT so parallel worktrees don't collide. @@ -600,8 +551,7 @@ def _repair_node_deps_on_current_checkout( gateway_mode: bool = False, pre_update_snapshot_id: str | None = None, completion_message: str = "✓ Already up to date!", - had_desktop_app_before_update: bool = False, -) -> bool: + had_desktop_app_before_update: bool = False) -> bool: """Repair Node deps on the ``commit_count == 0`` path. A current checkout doesn't imply healthy Node deps (a failed npm install says "re-run @@ -613,8 +563,7 @@ def _repair_node_deps_on_current_checkout( _check_and_apply_config_migration, _m, _rebuild_desktop_after_update, - _update_node_dependencies, - ) + _update_node_dependencies) node_failures = _update_node_dependencies() if node_failures: print(f" ⚠ Node.js refresh failed for: {', '.join(node_failures)}") @@ -626,19 +575,16 @@ def _repair_node_deps_on_current_checkout( _check_and_apply_config_migration( assume_yes=assume_yes, gateway_mode=gateway_mode, - pre_update_snapshot_id=pre_update_snapshot_id, - ) + pre_update_snapshot_id=pre_update_snapshot_id) # A current checkout can still owe a Desktop rebuild (e.g. the Windows hand-off child # never reaches the commits-pulled rebuild). Self-gates on the build stamp. if not _rebuild_desktop_after_update( _m().PROJECT_ROOT / "apps" / "desktop", - had_desktop_app_before_update=had_desktop_app_before_update, - ): + had_desktop_app_before_update=had_desktop_app_before_update): # Retry hint already printed; withhold success rather than claim completion. print_completion( "⚠ Update partially complete — the desktop app was not rebuilt " - "and is still on the previous build." - ) + "and is still on the previous build.") return False return bool(print_completion(completion_message)) @@ -665,8 +611,7 @@ def _update_node_dependencies() -> list[str]: failed = [] if any( (_m().PROJECT_ROOT / workspace / "package.json").exists() - for workspace in ("ui-tui", "web") - ): + for workspace in ("ui-tui", "web")): failed.append("ui-tui, web workspaces") return failed return [] @@ -704,8 +649,7 @@ def _update_node_dependencies() -> list[str]: "--workspace", "ui-tui", "--workspace", "web", # Root devDependencies (shared ESLint config) would otherwise be pruned by the # scoped install; apps/desktop stays excluded since it is never named above. - "--include-workspace-root", - ] + "--include-workspace-root"] from hermes_constants import with_hermes_node_path @@ -714,12 +658,7 @@ def _update_node_dependencies() -> list[str]: # capture_output=False is deliberate: postinstall scripts print download progress and # capturing makes a long download look hung. result = _m()._run_npm_install_deterministic( - npm, - _m().PROJECT_ROOT, - extra_args=tuple(install_args), - capture_output=False, - env=nixos_env, - ) + npm, _m().PROJECT_ROOT, extra_args=tuple(install_args), capture_output=False, env=nixos_env) if result.returncode == 0: _record_npm_lockfile_hash(shared_hermes_root) print(" ✓ ui-tui, web workspaces installed (desktop skipped)") @@ -748,9 +687,7 @@ def _venv_core_imports_healthy() -> tuple[bool, str]: # No venv: normal for a dev checkout (healthy), but on a MANAGED install (bootstrap # stamp or `.update-incomplete`) the venv IS the install — absence means an interrupted repair. managed_markers = ( - _m().PROJECT_ROOT / ".hermes-bootstrap-complete", - _m()._update_marker_path(), - ) + _m().PROJECT_ROOT / ".hermes-bootstrap-complete", _m()._update_marker_path()) if any(m.exists() for m in managed_markers): return False, f"venv python missing ({venv_python})" return True, "" @@ -764,15 +701,11 @@ def _venv_core_imports_healthy() -> tuple[bool, str]: "for m in mods:\n" " try: importlib.import_module(m)\n" " except Exception as e: missing.append(f'{m}: {e}')\n" - "print('\\n'.join(missing))\n" - ) + "print('\\n'.join(missing))\n") try: result = subprocess.run( - [str(venv_python), "-c", check], - capture_output=True, - text=True, encoding="utf-8", errors="replace", - timeout=60, - cwd=_m().PROJECT_ROOT, + [str(venv_python), "-c", check], capture_output=True, text=True, encoding="utf-8", + errors="replace", timeout=60, cwd=_m().PROJECT_ROOT, ) except Exception as exc: logger.debug("venv health probe failed to run: %s", exc) @@ -797,8 +730,7 @@ def _venv_core_imports_healthy() -> tuple[bool, str]: # Keys are ``sys.modules`` prefixes; values are ``(display name, PyPI dist)``. _SELF_LOCKING_NATIVE_MODULES: dict[str, tuple[str, str]] = { "cryptography.hazmat.bindings._rust": ("cryptography (_rust.pyd)", "cryptography"), - "yaml._yaml": ("PyYAML (_yaml.pyd)", "pyyaml"), -} + "yaml._yaml": ("PyYAML (_yaml.pyd)", "pyyaml")} def _dependency_sync_would_rewrite(dist_name: str) -> bool | None: @@ -912,13 +844,11 @@ def _desktop_app_present(desktop_dir: Path) -> bool: from hermes_cli.update_cmd import _m return ( _m()._desktop_packaged_executable(desktop_dir) is not None - or _m()._desktop_dist_exists(desktop_dir) - ) + or _m()._desktop_dist_exists(desktop_dir)) def _rebuild_desktop_after_update( - desktop_dir: Path, *, had_desktop_app_before_update: bool -) -> bool: + desktop_dir: Path, *, had_desktop_app_before_update: bool) -> bool: """Rebuild an installed Desktop app when its source or artifact changed. Returns ``False`` only when a rebuild was attempted and failed (caller withholds ``✓ Update complete!`` and writes a failing ``.update_exit_code`` in gateway mode); every other outcome is ``True``.""" @@ -929,8 +859,7 @@ def _rebuild_desktop_after_update( if not ( (desktop_dir / "package.json").exists() and _m()._resolve_node_runtime_npm() - and has_desktop_app - ): + and has_desktop_app): return True print("→ Checking if desktop app needs rebuilding...") @@ -940,8 +869,7 @@ def _rebuild_desktop_after_update( skip_desktop_build = False try: skip_desktop_build = not _m()._desktop_build_needed( - desktop_dir, _m().PROJECT_ROOT, source_mode=False - ) + desktop_dir, _m().PROJECT_ROOT, source_mode=False) except Exception: skip_desktop_build = False if skip_desktop_build: @@ -956,12 +884,10 @@ def _rebuild_desktop_after_update( build_env = with_hermes_node_path() build_result = _m()._run_logged_subprocess( - desktop_build_cmd, cwd=_m().PROJECT_ROOT, env=build_env - ) + desktop_build_cmd, cwd=_m().PROJECT_ROOT, env=build_env) if build_result.returncode != 0: build_result = _m()._run_logged_subprocess( - desktop_build_cmd, cwd=_m().PROJECT_ROOT, env=build_env - ) + desktop_build_cmd, cwd=_m().PROJECT_ROOT, env=build_env) if build_result.returncode != 0: print(" ⚠ Desktop build failed (run `hermes desktop` to retry)") tail = "\n".join((build_result.stdout or "").strip().splitlines()[-15:]) @@ -1076,8 +1002,7 @@ def _sync_python_dependencies_after_pull( *, active_lazy_features, active_tool_dependencies, - _windows_gateway_resume, -): + _windows_gateway_resume): """Reinstall Python deps for the pulled checkout. Order matters: ownership preflight -> self-lock deferral -> core marker -> ``.[all]`` -> bytecode sweep -> lazy/tool refresh (own marker) -> memory-provider deps -> critical-import probe (warn only; stale bytecode self-heals).""" @@ -1086,8 +1011,7 @@ def _sync_python_dependencies_after_pull( _sweep_bytecode_after_update, _validate_critical_modules_import, _write_lazy_refresh_incomplete_marker, - _write_update_incomplete_marker, - ) + _write_update_incomplete_marker) _refuse_update_if_venv_foreign_owned(_m().PROJECT_ROOT) # Self-lock deferral: if THIS process holds a native extension the sync must rewrite, defer # NOW (after the code swap) so only the install is pending for the next launch's marker. @@ -1128,8 +1052,7 @@ def _sync_python_dependencies_after_pull( print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...") _install_psutil_android_compat([uv_bin, "pip"], env=uv_env) _m()._install_python_dependencies_with_optional_fallback( - [uv_bin, "pip"], env=uv_env, group=install_group - ) + [uv_bin, "pip"], env=uv_env, group=install_group) else: # sys.executable -m pip avoids PEP 668 'externally-managed-environment' errors. pip_cmd = [sys.executable, "-m", "pip"] @@ -1168,17 +1091,13 @@ def _sync_python_dependencies_after_pull( # Clear the lazy marker only when refresh/repair is confirmed healthy. lazy_ok = _m()._refresh_active_lazy_features( - install_prefix, - env=lazy_env, - features=active_lazy_features, - ) + install_prefix, env=lazy_env, features=active_lazy_features) if lazy_ok: _m()._clear_lazy_refresh_incomplete_marker() else: print( " ⚠ Lazy-refresh recovery incomplete — run `hermes` again " - "to finish import-based venv repair." - ) + "to finish import-based venv repair.") _m()._restore_active_tool_dependencies(active_tool_dependencies, install_prefix, env=lazy_env) From 882b655f21d1dc0a297f9c262e8074a6b78e4cd6 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:55:00 -0700 Subject: [PATCH 2/5] =?UTF-8?q?refactor(hermes=5Fcli):=20dedupe=20update?= =?UTF-8?q?=5Fcmd/update=5Fcmd=5Fdeps=20=E2=80=94=20=5Fupdates=5Fconfig,?= =?UTF-8?q?=20=5Fprint=5Fupdate=5Fcheck=5Fresult,=20unified=20uv/pip=20rep?= =?UTF-8?q?air+sync=20branches,=20=5Fgit=5Frun=20for=20raw=20git=20calls,?= =?UTF-8?q?=20=5Fprobe=5Ffailure/=5Fclip/=5Fnpm=5Flock=5Fcache=5Ffile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/update_cmd.py | 252 ++++++++++++---------------------- hermes_cli/update_cmd_deps.py | 166 ++++++++++------------ 2 files changed, 162 insertions(+), 256 deletions(-) diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 218cdb93f5..6f3ffd4174 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -124,6 +124,14 @@ def _m(): return main +def _updates_config() -> dict: + """The ``updates:`` config section (``{}`` when absent/malformed); may raise on config errors.""" + from hermes_cli.config import load_config + + section = (load_config() or {}).get("updates", {}) + return section if isinstance(section, dict) else {} + + def _no_prompt_git_kwargs() -> dict: """``subprocess.run`` kwargs for network git calls: GitHub answers anonymous fetches with 401 during outages and git would then block forever on ``Username for ...``; disable only @@ -267,12 +275,8 @@ def _called_process_error_is_python_dep_install( if not parts: return False exe = os.path.basename(parts[0].replace("\\", "/")) - if "ensurepip" in parts: - return True - if "install" in parts and ( - "pip" in parts or exe in {"pip", "pip.exe", "pip3", "pip3.exe", "uv", "uv.exe"}): - return True - return False + return "ensurepip" in parts or ("install" in parts and ( + "pip" in parts or exe in {"pip", "pip.exe", "pip3", "pip3.exe", "uv", "uv.exe"})) def _format_update_failure_stage(exc: subprocess.CalledProcessError) -> str: @@ -345,19 +349,14 @@ def _print_called_process_error_tail( def _invalidate_update_cache(): """Delete the update-check cache for ALL profiles: the repo is shared, so one profile's update makes every profile current and a stale "commits behind" banner would linger.""" - homes = [] default_home = get_default_hermes_root() - homes.append(default_home) profiles_root = default_home / "profiles" + homes = [default_home] if profiles_root.is_dir(): - for entry in profiles_root.iterdir(): - if entry.is_dir(): - homes.append(entry) + homes += [entry for entry in profiles_root.iterdir() if entry.is_dir()] for home in homes: with suppress(Exception): - cache_file = home / ".update_check" - if cache_file.exists(): - cache_file.unlink() + (home / ".update_check").unlink(missing_ok=True) def _write_marker_file(path: Path, *, label: str) -> None: @@ -385,24 +384,29 @@ def _format_concurrent_instances_message( matches: list[tuple[int, str]], scripts_dir: Path) -> str: """Build a human-readable explanation + remediation hint for the user.""" shim = scripts_dir / "hermes.exe" - lines = ["✗ Another hermes.exe is running:"] - for pid, name in matches: - lines.append(f" PID {pid} {name}") - lines.append("") - lines.append(f" Updating now would fail to overwrite {shim} because") - lines.append(" Windows blocks REPLACE on a running executable.") - lines.append("") - lines.append(" Close Hermes Desktop, exit any open `hermes` REPLs, and") - lines.append(" stop the gateway (`hermes gateway stop`) before retrying.") - lines.append("") + lines = [ + "✗ Another hermes.exe is running:", + *(f" PID {pid} {name}" for pid, name in matches), + "", + f" Updating now would fail to overwrite {shim} because", + " Windows blocks REPLACE on a running executable.", + "", + " Close Hermes Desktop, exit any open `hermes` REPLs, and", + " stop the gateway (`hermes gateway stop`) before retrying.", + "", + ] if matches: pid_args = " ".join(f"/PID {pid}" for pid, _ in matches) - lines.append(" If you've already closed everything and these PIDs are") - lines.append(" stale, terminate them directly, then retry the update:") - lines.append(f" taskkill {pid_args} /F") - lines.append("") - lines.append(" Override with `hermes update --force` if you've already") - lines.append(" confirmed those processes will not write to the venv.") + lines += [ + " If you've already closed everything and these PIDs are", + " stale, terminate them directly, then retry the update:", + f" taskkill {pid_args} /F", + "", + ] + lines += [ + " Override with `hermes update --force` if you've already", + " confirmed those processes will not write to the venv.", + ] return "\n".join(lines) @@ -426,21 +430,14 @@ def _classify_concurrent_instance(pid: int) -> str: from hermes_cli._scan_venv_blockers import _is_pausable_gateway # noqa: PLC0415 - cmdline = " ".join(cmdline_list or []) - if _is_pausable_gateway(cmdline): - return "gateway" - return "non-gateway" + return "gateway" if _is_pausable_gateway(" ".join(cmdline_list or [])) else "non-gateway" def _filter_non_gateway_concurrent_instances( matches: list[tuple[int, str]]) -> list[tuple[int, str]]: """Drop gateway matches (the pause + post-update restart machinery handles them); anything else (TUI, Desktop backend child, another REPL) has no pause path, so the gate aborts.""" - non_gateway: list[tuple[int, str]] = [] - for pid, name in matches: - if _classify_concurrent_instance(pid) != "gateway": - non_gateway.append((pid, name)) - return non_gateway + return [(pid, name) for pid, name in matches if _classify_concurrent_instance(pid) != "gateway"] def _log_only_write(text: str) -> None: @@ -512,19 +509,13 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False): _git_run(git_cmd, ["rev-parse", "--is-shallow-repository"]).stdout.strip() == "true") depth_args = ["--depth", "1"] if is_shallow else [] - if branch == "main": - # Probe locally for an 'upstream' remote before a network fetch non-forks always fail. - has_upstream_remote = _git_run(git_cmd, ["remote", "get-url", "upstream"]).returncode == 0 - fetch_result = None - if has_upstream_remote: - print("→ Fetching from upstream...") - fetch_result = _git_run(git_cmd, ["fetch"] + depth_args + ["upstream", branch], network=True) - if fetch_result is not None and fetch_result.returncode == 0: - compare_branch = f"upstream/{branch}" - else: - print("→ Fetching from origin...") - fetch_result = _git_run(git_cmd, ["fetch"] + depth_args + ["origin", branch], network=True) - compare_branch = f"origin/{branch}" + # Probe locally for an 'upstream' remote before a network fetch non-forks always fail. + fetch_result = None + if branch == "main" and _git_run(git_cmd, ["remote", "get-url", "upstream"]).returncode == 0: + print("→ Fetching from upstream...") + fetch_result = _git_run(git_cmd, ["fetch"] + depth_args + ["upstream", branch], network=True) + if fetch_result is not None and fetch_result.returncode == 0: + compare_branch = f"upstream/{branch}" else: print("→ Fetching from origin...") fetch_result = _git_run(git_cmd, ["fetch"] + depth_args + ["origin", branch], network=True) @@ -547,33 +538,30 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False): target_sha = _git_run(git_cmd, ["rev-parse", compare_branch]).stdout.strip() if head_sha and target_sha and head_sha == target_sha: print("✓ Already up to date.") - else: - from hermes_cli.banner import _github_compare_behind - from hermes_cli.config import recommended_update_command + return + from hermes_cli.banner import _github_compare_behind - counted = _github_compare_behind(head_sha, target_sha) - if counted == 0: # local-ahead, not behind - print("✓ Already up to date.") - return - if counted is not None: - commits_word = "commit" if counted == 1 else "commits" - print(f"⚕ Update available: {counted} {commits_word} behind {compare_branch}.") - else: - print(f"⚕ Update available (behind {compare_branch}).") - print(f" Run '{recommended_update_command()}' to install.") + # counted == 0 means local-ahead, not behind; None means the API could not count. + _print_update_check_result(_github_compare_behind(head_sha, target_sha), compare_branch) return rev_result = _git_run(git_cmd, ["rev-list", f"HEAD..{compare_branch}", "--count"], check=True) - behind = int(rev_result.stdout.strip()) + _print_update_check_result(int(rev_result.stdout.strip()), compare_branch) + +def _print_update_check_result(behind: int | None, compare_branch: str) -> None: + """Report ``--check``'s verdict: up to date, N commits behind, or behind by an unknown count.""" if behind == 0: print("✓ Already up to date.") - else: + return + if behind is not None: commits_word = "commit" if behind == 1 else "commits" print(f"⚕ Update available: {behind} {commits_word} behind {compare_branch}.") - from hermes_cli.config import recommended_update_command + else: + print(f"⚕ Update available (behind {compare_branch}).") + from hermes_cli.config import recommended_update_command - print(f" Run '{recommended_update_command()}' to install.") + print(f" Run '{recommended_update_command()}' to install.") def _repair_current_checkout( @@ -628,21 +616,16 @@ def _repair_current_checkout( # Isolated from third-party UV env vars, like the other dependency syncs. from hermes_cli.managed_uv import managed_python_env - repair_env = managed_python_env() + repair_prefix, repair_env = [repair_uv, "pip"], managed_python_env() repair_env["VIRTUAL_ENV"] = str(_m().PROJECT_ROOT / "venv") - _m()._install_python_dependencies_with_optional_fallback( - [repair_uv, "pip"], env=repair_env, group="all") - _m()._refresh_active_lazy_features( - [repair_uv, "pip"], env=repair_env, features=active_lazy_features) - _m()._restore_active_tool_dependencies( - active_tool_dependencies, [repair_uv, "pip"], env=repair_env) else: - _m()._install_python_dependencies_with_optional_fallback( - [sys.executable, "-m", "pip"], group="all") - _m()._refresh_active_lazy_features( - [sys.executable, "-m", "pip"], features=active_lazy_features) - _m()._restore_active_tool_dependencies( - active_tool_dependencies, [sys.executable, "-m", "pip"]) + repair_prefix, repair_env = [sys.executable, "-m", "pip"], None + _m()._install_python_dependencies_with_optional_fallback( + repair_prefix, env=repair_env, group="all") + _m()._refresh_active_lazy_features( + repair_prefix, env=repair_env, features=active_lazy_features) + _m()._restore_active_tool_dependencies( + active_tool_dependencies, repair_prefix, env=repair_env) _m()._clear_update_incomplete_marker() healthy_after, detail_after = _venv_core_imports_healthy() if healthy_after: @@ -696,18 +679,10 @@ def _reconcile_diverged_checkout(git_cmd, branch: str, pre_pull_sha) -> None: f" ⚠ Checkout is on custom branch '{_cur_branch}' — " f"merging origin/{branch} instead of resetting so local commits survive...") # Best-effort safety tag as a recovery anchor. - subprocess.run( - git_cmd - + ["tag", f"pre-update-{_time.strftime('%Y%m%d-%H%M%S')}"], - cwd=_m().PROJECT_ROOT, - capture_output=True, - check=False) + _git_run(git_cmd, ["tag", f"pre-update-{_time.strftime('%Y%m%d-%H%M%S')}"]) merge_result = _git_run(git_cmd, ["merge", "--no-edit", f"origin/{branch}"]) if merge_result.returncode != 0: - subprocess.run( - git_cmd + ["merge", "--abort"], cwd=_m().PROJECT_ROOT, capture_output=True, - check=False, - ) + _git_run(git_cmd, ["merge", "--abort"]) print( "✗ Merge conflict between local commits and upstream — " "update stopped, nothing was changed.") @@ -720,8 +695,7 @@ def _reconcile_diverged_checkout(git_cmd, branch: str, pre_pull_sha) -> None: # reset. Orphan divergence (no common ancestor: corrupted HEAD, re-init) would # lose the whole local graph, so park pre_pull_sha behind a rescue ref first. merge_base_result = _git_run(git_cmd, ["merge-base", "HEAD", f"origin/{branch}"]) - has_common_ancestor = bool( - merge_base_result.returncode == 0 and merge_base_result.stdout.strip()) + has_common_ancestor = merge_base_result.returncode == 0 and merge_base_result.stdout.strip() if not has_common_ancestor and pre_pull_sha: from datetime import datetime as _dt, timezone @@ -882,13 +856,8 @@ def _apply_parked_branch_guard( if switch_block_reason.startswith("unmerged:"): _in_place_configured = False with _best_effort('Could not read updates.parked_branch_strategy: %s'): - from hermes_cli.config import load_config as _load_cfg - - _upd_cfg = (_load_cfg() or {}).get("updates", {}) _in_place_configured = ( - isinstance(_upd_cfg, dict) - and _upd_cfg.get("parked_branch_strategy", "switch") - == "update_in_place") + _updates_config().get("parked_branch_strategy", "switch") == "update_in_place") if _in_place_configured and not switch_branch: # --branch typos used to surface via the checkout failing, which this path skips. verify_ref = _git_run(git_cmd, ["rev-parse", "--verify", "--quiet", f"origin/{branch}"]) @@ -1035,17 +1004,10 @@ def _resolve_update_options(args, gateway_mode: bool) -> _UpdateOptions: gateway_mode or assume_yes or not (sys.stdin.isatty() and sys.stdout.isatty())) discard_local_changes = False if _non_interactive_update: - try: - from hermes_cli.config import load_config - - _update_cfg = (load_config() or {}).get("updates", {}) - if isinstance(_update_cfg, dict): - _mode = str(_update_cfg.get("non_interactive_local_changes", "stash")).lower() - discard_local_changes = _mode == "discard" - except Exception as exc: - # A config read failure must never change the safe default. - logger.debug("Could not read updates.non_interactive_local_changes: %s", exc) - discard_local_changes = False + # A config read failure must never change the safe default. + with _best_effort("Could not read updates.non_interactive_local_changes: %s"): + _mode = str(_updates_config().get("non_interactive_local_changes", "stash")).lower() + discard_local_changes = _mode == "discard" return _UpdateOptions( active_lazy_features=active_lazy_features, active_tool_dependencies=active_tool_dependencies, pre_update_version=pre_update_version, @@ -1108,22 +1070,11 @@ def _prepare_git_command() -> tuple[bool, list, bool]: sys.exit(1) # Windows git can fail "unable to write loose object file: Invalid argument" (fs atomicity). - if sys.platform == "win32" and git_dir.exists(): - subprocess.run( - [ - "git", - "-c", - "windows.appendAtomically=false", - "config", - "windows.appendAtomically", - "false"], - cwd=_m().PROJECT_ROOT, - check=False, - capture_output=True) - git_cmd = ["git"] if sys.platform == "win32": git_cmd = ["git", "-c", "windows.appendAtomically=false"] + if git_dir.exists(): + _git_run(git_cmd, ["config", "windows.appendAtomically", "false"]) # A broken Git-for-Windows trampoline refuses every call with a "BUG (fork bomb)" guard; # swap in a real binary up front so git survives instead of degrading to ZIP. git_cmd = _ensure_non_trampoline_git(git_cmd) @@ -1228,25 +1179,17 @@ def _finish_already_up_to_date( _windows_gateway_resume) -> None: """"Already up to date" path: restore stash/branch, repair the checkout, catch up the fleet. ``sys.exit(1)`` when the repair is incomplete (after gateway exit code + partial receipt).""" - auto_stash_ref = _plan.auto_stash_ref - parked_branch_switched = _plan.parked_branch_switched - prompt_for_restore = _plan.prompt_for_restore - switch_block_reason = _plan.switch_block_reason - upstream_checked = _plan.upstream_checked _invalidate_update_cache() # Restore stash and switch back if we moved. EXCEPTION: a parked branch verified clean + # fully merged stays on the target — re-parking on the stale branch recreates the incident. - if auto_stash_ref is not None: + if _plan.auto_stash_ref is not None: _m()._restore_stashed_changes( - git_cmd, - _m().PROJECT_ROOT, - auto_stash_ref, - prompt_user=prompt_for_restore, + git_cmd, _m().PROJECT_ROOT, _plan.auto_stash_ref, prompt_user=_plan.prompt_for_restore, input_fn=gw_input_fn) - if parked_branch_switched: - if switch_block_reason.startswith("unmerged:"): - _count = switch_block_reason.split(":", 1)[1] + if _plan.parked_branch_switched: + if _plan.switch_block_reason.startswith("unmerged:"): + _count = _plan.switch_block_reason.split(":", 1)[1] print( f" ✓ Checkout was parked on '{current_branch}' — " f"switched back to {branch}; {_count} unmerged " @@ -1263,9 +1206,8 @@ def _finish_already_up_to_date( pre_update_snapshot_id=pre_update_snapshot_id, desktop_dir=desktop_dir, had_desktop_app_before_update=had_desktop_app_before_update, active_lazy_features=active_lazy_features, - active_tool_dependencies=active_tool_dependencies, upstream_checked=upstream_checked, - _windows_gateway_resume=_windows_gateway_resume, - ) + active_tool_dependencies=active_tool_dependencies, upstream_checked=_plan.upstream_checked, + _windows_gateway_resume=_windows_gateway_resume) _m()._resume_windows_gateways_after_update(_windows_gateway_resume) # A prior pull may still owe the fleet a restart; catch up here too, BEFORE the exit # gate so a partial outcome can't strand the fleet on stale code. @@ -1286,12 +1228,7 @@ def _cmd_update_impl(args, gateway_mode: bool): opts = _resolve_update_options(args, gateway_mode) active_lazy_features = opts.active_lazy_features active_tool_dependencies = opts.active_tool_dependencies - pre_update_version = opts.pre_update_version - gw_input_fn = opts.gw_input_fn - assume_yes = opts.assume_yes - keep_stash = opts.keep_stash - switch_branch = opts.switch_branch - discard_local_changes = opts.discard_local_changes + gw_input_fn, assume_yes = opts.gw_input_fn, opts.assume_yes print("⚕ Updating Hermes Agent...") print() @@ -1368,13 +1305,9 @@ def _cmd_update_impl(args, gateway_mode: bool): _plan = _prepare_checkout_for_update( git_cmd, branch, current_branch, is_fork=is_fork, assume_yes=assume_yes, - gateway_mode=gateway_mode, gw_input_fn=gw_input_fn, switch_branch=switch_branch, - _windows_gateway_resume=_windows_gateway_resume, - ) - auto_stash_ref = _plan.auto_stash_ref + gateway_mode=gateway_mode, gw_input_fn=gw_input_fn, switch_branch=opts.switch_branch, + _windows_gateway_resume=_windows_gateway_resume) commit_count = _plan.commit_count - in_place_update = _plan.in_place_update - prompt_for_restore = _plan.prompt_for_restore if commit_count == 0: _finish_already_up_to_date( @@ -1396,17 +1329,15 @@ def _cmd_update_impl(args, gateway_mode: bool): print("→ Pulling updates...") pre_pull_sha = _pull_updates( - git_cmd, branch, auto_stash_ref, prompt_for_restore=prompt_for_restore, - gw_input_fn=gw_input_fn, discard_local_changes=discard_local_changes, - keep_stash=keep_stash, - ) + git_cmd, branch, _plan.auto_stash_ref, prompt_for_restore=_plan.prompt_for_restore, + gw_input_fn=gw_input_fn, discard_local_changes=opts.discard_local_changes, + keep_stash=opts.keep_stash) _invalidate_update_cache() post_pull_sha = _verify_head_after_pull( - git_cmd, branch, pre_pull_sha, in_place_update=in_place_update, - _windows_gateway_resume=_windows_gateway_resume, - ) + git_cmd, branch, pre_pull_sha, in_place_update=_plan.in_place_update, + _windows_gateway_resume=_windows_gateway_resume) # Gateways still serve pre-pull modules until the restart phase; an interrupt before a # completed restart leaves this marker so the next update catches up even when git is @@ -1442,8 +1373,7 @@ def _cmd_update_impl(args, gateway_mode: bool): pre_update_snapshot_id=pre_update_snapshot_id, had_desktop_app_before_update=had_desktop_app_before_update, node_failures=node_failures, desktop_build_ok=desktop_build_ok, - pre_update_version=pre_update_version, - ) + pre_update_version=opts.pre_update_version) # Exit code *before* the restart: under --gateway this process lives in the gateway's # systemd cgroup and the systemctl-restart fallback SIGKILLs it (KillMode=mixed), so diff --git a/hermes_cli/update_cmd_deps.py b/hermes_cli/update_cmd_deps.py index 2c50bf6b03..eef2c4232d 100644 --- a/hermes_cli/update_cmd_deps.py +++ b/hermes_cli/update_cmd_deps.py @@ -44,9 +44,7 @@ def _editable_install_is_current(git_cmd, cwd, pre_pull_sha: str | None) -> bool text=True, encoding="utf-8", errors="replace") except OSError: return False - if result.returncode != 0: - return False - return not result.stdout.strip() + return result.returncode == 0 and not result.stdout.strip() # Modules imported on every startup. Unlike _UPDATE_CRITICAL_FILES (only parsed) these are @@ -108,22 +106,16 @@ def _critical_module_import_failures( encoding="utf-8", errors="replace", timeout=120, ) except subprocess.TimeoutExpired: - return { - "critical-module probe": ( - "TimeoutExpired", "timed out before reporting import health")} + return _probe_failure("TimeoutExpired", "timed out before reporting import health") except (OSError, subprocess.SubprocessError): # Can't run the probe — don't block the update on our own tooling. return {} output = result.stdout or "" if marker not in output: - return { - "critical-module probe": ( - "ProbeTerminated", - "terminated before reporting import health " - f"(exit code {result.returncode})")} + return _probe_failure( + "ProbeTerminated", + f"terminated before reporting import health (exit code {result.returncode})") try: - import json - failures = json.loads(output.rsplit(marker, 1)[1]) if not isinstance(failures, list) or any( not isinstance(item, list) @@ -133,9 +125,12 @@ def _critical_module_import_failures( raise ValueError("invalid import-health payload") return {str(module): (str(kind), str(detail)) for module, kind, detail in failures} except (TypeError, ValueError): - return { - "critical-module probe": ( "MalformedPayload", "reported malformed import health data" ) - } + return _probe_failure("MalformedPayload", "reported malformed import health data") + + +def _probe_failure(kind: str, detail: str) -> dict[str, tuple[str, str]]: + """Failure row for the probe itself (as opposed to a module it imported).""" + return {"critical-module probe": (kind, detail)} def _validate_critical_modules_import( @@ -280,9 +275,12 @@ def _restore_active_tool_dependencies( if restored: print(f" ✓ {len(restored)} restored: {', '.join(restored)}") for name, reason in failed: - if len(reason) > 200: - reason = reason[:200] + "..." - print(f" ⚠ {name} failed to restore: {reason}") + print(f" ⚠ {name} failed to restore: {_clip(reason)}") + + +def _clip(reason: str, limit: int = 200) -> str: + """Bound a failure reason for the one-line report.""" + return reason if len(reason) <= limit else reason[:limit] + "..." def _refresh_active_lazy_features( @@ -304,15 +302,13 @@ def _refresh_active_lazy_features( logger.debug("Lazy refresh skipped (import failed): %s", exc) return True - if features is None: + active = features + if active is None: try: active = lazy_deps.active_features() except Exception as exc: logger.debug("Lazy refresh skipped (active_features failed): %s", exc) return True - else: - active = features - if not active: return True @@ -350,10 +346,7 @@ def _refresh_active_lazy_features( return True for feature, status in failed: - reason = status.split(": ", 1)[-1] - if len(reason) > 200: - reason = reason[:200] + "..." - print(f" ⚠ {feature} failed to refresh: {reason}") + print(f" ⚠ {feature} failed to refresh: {_clip(status.split(': ', 1)[-1])}") if install_cmd_prefix is None: print(" ⚠ Lazy refresh failed; rerun `hermes update` once resolved.") @@ -521,9 +514,7 @@ def _npm_lockfile_changed(hermes_root: Path) -> bool: *_web_toolchain_roots(web_dir)): return True try: - # Key the cache by PROJECT_ROOT so parallel worktrees don't collide. - cache_key = hashlib.sha256(str(_m().PROJECT_ROOT).encode()).hexdigest()[:12] - cache_file = hermes_root / f".npm_lock_hash_{cache_key}" + cache_file = _npm_lock_cache_file(hermes_root) if not cache_file.exists(): return True return cache_file.read_text(encoding="utf-8").strip() != current @@ -531,15 +522,19 @@ def _npm_lockfile_changed(hermes_root: Path) -> bool: return True -def _record_npm_lockfile_hash(hermes_root: Path) -> None: +def _npm_lock_cache_file(hermes_root: Path) -> Path: + """Per-checkout cache path: keyed by PROJECT_ROOT so parallel worktrees don't collide.""" from hermes_cli.update_cmd import _m + cache_key = hashlib.sha256(str(_m().PROJECT_ROOT).encode()).hexdigest()[:12] + return hermes_root / f".npm_lock_hash_{cache_key}" + + +def _record_npm_lockfile_hash(hermes_root: Path) -> None: digest = _npm_manifests_digest() if digest is None: return try: - cache_key = hashlib.sha256(str(_m().PROJECT_ROOT).encode()).hexdigest()[:12] - cache_file = hermes_root / f".npm_lock_hash_{cache_key}" - cache_file.write_text(digest, encoding="utf-8") + _npm_lock_cache_file(hermes_root).write_text(digest, encoding="utf-8") except OSError: logger.debug("Could not write npm lockfile hash cache") @@ -608,12 +603,9 @@ def _update_node_dependencies() -> list[str]: print(" ⚠ Skipped: only a Windows npm is reachable from this WSL shell.") print(" Install Node.js inside the WSL distro (nvm, or your distro's") print(" package manager), then re-run `hermes update`.") - failed = [] - if any( - (_m().PROJECT_ROOT / workspace / "package.json").exists() - for workspace in ("ui-tui", "web")): - failed.append("ui-tui, web workspaces") - return failed + has_workspace = any( + (_m().PROJECT_ROOT / ws / "package.json").exists() for ws in ("ui-tui", "web")) + return ["ui-tui, web workspaces"] if has_workspace else [] return [] from hermes_constants import get_default_hermes_root @@ -662,15 +654,12 @@ def _update_node_dependencies() -> list[str]: if result.returncode == 0: _record_npm_lockfile_hash(shared_hermes_root) print(" ✓ ui-tui, web workspaces installed (desktop skipped)") - failures: list[str] = [] - else: - print(" ⚠ npm install failed") - stderr = (result.stderr or "").strip() if result.stderr else "" - if stderr: - print(f" {stderr.splitlines()[-1]}") - failures = _partial_update_failure("ui-tui, web workspaces") - - return failures + return [] + print(" ⚠ npm install failed") + stderr = (result.stderr or "").strip() + if stderr: + print(f" {stderr.splitlines()[-1]}") + return _partial_update_failure("ui-tui, web workspaces") def _venv_core_imports_healthy() -> tuple[bool, str]: @@ -789,17 +778,12 @@ def _detect_self_loaded_native_modules() -> list[str]: from hermes_cli.update_cmd import _m if not _m()._is_windows(): return [] - found = [] - for prefix, (display, dist) in _SELF_LOCKING_NATIVE_MODULES.items(): - if prefix not in sys.modules: - continue - # Defer ONLY on a CONFIRMED rewrite; unknown fails OPEN (PyYAML is in every process, so - # unknown-as-at-risk always fires). A missed deferral only yields the mid-sync os error 5 - # that marker recovery already handles — far less harmful than an update that never runs. - if _m()._dependency_sync_would_rewrite(dist) is not True: - continue - found.append(display) - return sorted(set(found)) + # Defer ONLY on a CONFIRMED rewrite; unknown fails OPEN (PyYAML is in every process, so + # unknown-as-at-risk always fires). A missed deferral only yields the mid-sync os error 5 + # that marker recovery already handles — far less harmful than an update that never runs. + return sorted({ + display for prefix, (display, dist) in _SELF_LOCKING_NATIVE_MODULES.items() + if prefix in sys.modules and _m()._dependency_sync_would_rewrite(dist) is True}) def _abort_dependency_sync_if_self_locked(gateway_resume=None) -> None: @@ -812,14 +796,14 @@ def _abort_dependency_sync_if_self_locked(gateway_resume=None) -> None: locked = _m()._detect_self_loaded_native_modules() if locked: _m()._defer_update_for_self_lock(locked) - if gateway_resume is not None: - _m()._resume_windows_gateways_after_update(gateway_resume) - sys.exit(2) - - if _m()._reexec_dependency_sync_off_windows_shim(): - if gateway_resume is not None: - _m()._resume_windows_gateways_after_update(gateway_resume) - sys.exit(0) + exit_code = 2 + elif _m()._reexec_dependency_sync_off_windows_shim(): + exit_code = 0 + else: + return + if gateway_resume is not None: + _m()._resume_windows_gateways_after_update(gateway_resume) + sys.exit(exit_code) def _defer_update_for_self_lock(loaded: list[str]) -> None: @@ -866,7 +850,6 @@ def _rebuild_desktop_after_update( # Check the content-hash stamp IN-PROCESS first (the subprocess spends ~1-3 s importing the # CLI to reach the same check). Update never passes --source, so source_mode=False. # Any pre-check error falls through to the subprocess. - skip_desktop_build = False try: skip_desktop_build = not _m()._desktop_build_needed( desktop_dir, _m().PROJECT_ROOT, source_mode=False) @@ -1031,6 +1014,7 @@ def _sync_python_dependencies_after_pull( uv_bin = ensure_uv() + # sys.executable -m pip avoids PEP 668 'externally-managed-environment' errors. pip_cmd = [sys.executable, "-m", "pip"] if not uv_bin: uv_bin = _ensure_uv_for_termux(pip_cmd) @@ -1040,34 +1024,26 @@ def _sync_python_dependencies_after_pull( # managed_python_env() isolation so a third-party UV_PYTHON_INSTALL_DIR can't hijack uv. from hermes_cli.managed_uv import managed_python_env - uv_env = managed_python_env() - uv_env["VIRTUAL_ENV"] = str(_m().PROJECT_ROOT / "venv") - if _m()._is_termux_env(uv_env): - uv_env.pop("PYTHONPATH", None) - uv_env.pop("PYTHONHOME", None) - install_group = "termux-all" - print(" → Termux detected: using uv + curated termux-all optional profile...") - if not deps_current: - if _m()._is_termux_env(uv_env) and _is_android_python(): - print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...") - _install_psutil_android_compat([uv_bin, "pip"], env=uv_env) - _m()._install_python_dependencies_with_optional_fallback( - [uv_bin, "pip"], env=uv_env, group=install_group) + install_prefix, lazy_env = [uv_bin, "pip"], managed_python_env() + lazy_env["VIRTUAL_ENV"] = str(_m().PROJECT_ROOT / "venv") + termux_note = " → Termux detected: using uv + curated termux-all optional profile..." else: - # sys.executable -m pip avoids PEP 668 'externally-managed-environment' errors. - pip_cmd = [sys.executable, "-m", "pip"] _ensure_venv_pip(pip_cmd, sys.executable) - if _m()._is_termux_env(): - install_group = "termux-all" - print(" → Termux detected: using curated termux-all optional profile...") - if not deps_current: - if _m()._is_termux_env() and _is_android_python(): - print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...") - _install_psutil_android_compat(pip_cmd) - _m()._install_python_dependencies_with_optional_fallback(pip_cmd, group=install_group) - - install_prefix = [uv_bin, "pip"] if uv_bin else pip_cmd - lazy_env = uv_env if uv_bin else None + install_prefix, lazy_env = pip_cmd, None + termux_note = " → Termux detected: using curated termux-all optional profile..." + is_termux = _m()._is_termux_env(lazy_env) + if is_termux: + if lazy_env is not None: + lazy_env.pop("PYTHONPATH", None) + lazy_env.pop("PYTHONHOME", None) + install_group = "termux-all" + print(termux_note) + if not deps_current: + if is_termux and _is_android_python(): + print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...") + _install_psutil_android_compat(install_prefix, env=lazy_env) + _m()._install_python_dependencies_with_optional_fallback( + install_prefix, env=lazy_env, group=install_group) if deps_current: # Verification normally runs inside the skipped install; run it here so a wrong skip From 2f45a8237485add8f2083b6849e89510b43c50df Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:00:54 -0700 Subject: [PATCH 3/5] =?UTF-8?q?refactor(hermes=5Fcli):=20uninstall=20?= =?UTF-8?q?=E2=80=94=20table-driven=20reinstall/reload=20hints,=20simpler?= =?UTF-8?q?=20remove-step=20helpers,=20compact=20Windows=20registry=20note?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/uninstall.py | 150 +++++++++++++++------------------------- 1 file changed, 57 insertions(+), 93 deletions(-) diff --git a/hermes_cli/uninstall.py b/hermes_cli/uninstall.py index cd4433558e..c0330cadfc 100644 --- a/hermes_cli/uninstall.py +++ b/hermes_cli/uninstall.py @@ -123,12 +123,12 @@ def remove_path_from_shell_configs(): def remove_wrapper_script(): """Remove the hermes wrapper script if it exists.""" def _unlink_ours(wrapper: Path) -> bool: - # Check if it's our wrapper (contains hermes_cli reference) + # Only our wrapper (contains a hermes_cli / hermes-agent reference). content = wrapper.read_text(encoding="utf-8") - if 'hermes_cli' in content or 'hermes-agent' in content: - wrapper.unlink() - return True - return False + if 'hermes_cli' not in content and 'hermes-agent' not in content: + return False + wrapper.unlink() + return True candidates = ( bin_dir / name @@ -164,17 +164,13 @@ def remove_node_symlinks(hermes_home: Path) -> list: # Only act on symlinks — never delete a real binary the user put here. if not link.is_symlink(): return False - # Resolve the link target and confirm it points into our node dir. os.readlink + manual - # join handles broken (dangling) links too; Path.resolve() on a dangling link still - # returns the target path. - target = Path(os.readlink(link)) - if not target.is_absolute(): - target = (link.parent / target) - target = target.resolve() - if target == node_dir or node_dir in target.parents: - link.unlink() - return True - return False + # os.readlink + manual join handles dangling links too (Path.resolve() on a dangling + # link still returns the target path); the link must point into OUR node dir. + target = (link.parent / os.readlink(link)).resolve() + if target != node_dir and node_dir not in target.parents: + return False + link.unlink() + return True candidates = (bin_dir / name for name in ("node", "npm", "npx") for bin_dir in _node_symlink_candidate_dirs()) return _remove_each(candidates, _unlink_ours) @@ -205,21 +201,18 @@ def uninstall_gateway_service(): return stopped_something # 2. Per-platform service removal (systemd / launchd / Scheduled Task). - step = _GATEWAY_SERVICE_REMOVERS.get(platform.system()) - if step is not None: - remover, warn_label = step + remover, warn_label = _GATEWAY_SERVICE_REMOVERS.get(platform.system(), (None, "")) + if remover is not None: try: stopped_something = remover() or stopped_something except Exception as e: log_warn(f"{warn_label}: {e}") - return stopped_something def _remove_systemd_gateway() -> bool: """Linux: uninstall systemd services (both user and system scopes).""" - from hermes_cli.gateway import ( - get_systemd_unit_path, get_service_name, _systemctl_cmd) + from hermes_cli.gateway import _systemctl_cmd, get_service_name, get_systemd_unit_path svc_name = get_service_name() removed_any = False @@ -287,36 +280,14 @@ _GATEWAY_SERVICE_REMOVERS = { "Darwin": (_remove_launchd_gateway, "Could not remove launchd gateway service"), "Windows": (_remove_windows_gateway, "Could not check Windows gateway service")} -# ============================================================================ -# Windows-specific uninstall helpers -# ============================================================================ -# -# The installer (``scripts/install.ps1``) does four Windows-only things that -# ``remove_path_from_shell_configs`` / ``remove_wrapper_script`` don't cover: -# -# 1. Sets User-scope env vars ``HERMES_HOME`` and ``HERMES_GIT_BASH_PATH`` -# via ``[Environment]::SetEnvironmentVariable(..., "User")``. These -# don't live in ~/.bashrc — they're in the Windows registry at -# HKCU\Environment. -# 2. Prepends to User-scope ``PATH`` (same registry location) entries -# like ``%LOCALAPPDATA%\hermes\git\cmd``, ``%LOCALAPPDATA%\hermes\git\bin``, -# ``%LOCALAPPDATA%\hermes\git\usr\bin``, ``%LOCALAPPDATA%\hermes\node``. -# Again not in any rc file — only accessible via the registry or the -# .NET [Environment] API. -# 3. Downloads PortableGit to ``%LOCALAPPDATA%\hermes\git\`` and Node to -# ``%LOCALAPPDATA%\hermes\node\`` as user-scoped, isolated copies. -# These are ~200MB combined and serve no purpose after uninstall. -# 4. On the ``hermes dashboard`` + gateway paths, drops files into -# ``%LOCALAPPDATA%\hermes\gateway-service\`` and sometimes -# ``%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\`` — the -# latter is handled by ``gateway_windows.uninstall()`` already. -# -# Running a PowerShell one-liner per operation is overkill and fragile on -# locked-down machines (Constrained Language Mode, restricted ExecutionPolicy). -# Direct registry writes via ``winreg`` work without spawning any subprocess -# and apply immediately for new shells (SendMessage WM_SETTINGCHANGE would -# be nicer but requires ctypes and buys us nothing — the user will log out -# or open a new terminal anyway). +# Windows-specific helpers. install.ps1 does four things no rc file covers: (1) User-scope env +# vars HERMES_HOME / HERMES_GIT_BASH_PATH in the registry (HKCU\Environment); (2) User-scope PATH +# entries there too (%LOCALAPPDATA%\hermes\git\{cmd,bin,usr\bin}, ...\hermes\node); (3) PortableGit +# + Node copies under %LOCALAPPDATA%\hermes\ (~200MB, useless after uninstall); (4) the +# gateway-service dir (the Startup-folder entry is handled by gateway_windows.uninstall()). +# Direct winreg writes are used instead of PowerShell one-liners: no subprocess, and they work +# under Constrained Language Mode / restricted ExecutionPolicy. New shells see them immediately +# (WM_SETTINGCHANGE would need ctypes and buys nothing — the user opens a new terminal anyway). def _hermes_path_markers(hermes_home: Path, *, include_managed_bin: bool = False) -> list[str]: @@ -333,13 +304,8 @@ def _hermes_path_markers(hermes_home: Path, *, include_managed_bin: bool = False def remove_path_from_windows_registry(hermes_home: Path, *, include_managed_bin: bool = False) -> list[str]: - """Strip Hermes-owned entries from User-scope PATH in the registry. - - ``include_managed_bin`` adds ``\bin`` (the managed binary dir holding the hermes - launchers and the managed uv) to the sweep. Only pass it when that dir is actually being deleted - — full uninstall from the default root — so a keep-data uninstall leaves the still-working - managed uv resolvable. - """ + """Strip Hermes-owned entries from User-scope PATH in the registry (see ``_hermes_path_markers`` + for what ``include_managed_bin`` adds and when to pass it).""" markers = tuple(m.lower() for m in _hermes_path_markers(hermes_home, include_managed_bin=include_managed_bin)) def edit(winreg, key, removed): @@ -400,12 +366,8 @@ def remove_portable_tooling_windows(hermes_home: Path) -> list[Path]: """Delete PortableGit and Node installs the Windows installer created under ``%LOCALAPPDATA%\\hermes\\``. Only called on full uninstall; they're isolated from any system Git / Node so they cannot break other tools.""" - def _rmtree(target: Path) -> bool: - shutil.rmtree(target, ignore_errors=False) - return True - targets = (hermes_home / sub for sub in ("git", "node", "gateway-service")) - return _remove_each((t for t in targets if t.exists()), _rmtree) + return _remove_each((t for t in targets if t.exists()), lambda t: shutil.rmtree(t) or True) def remove_windows_bin_launchers(*, windows: bool | None = None) -> list[Path]: @@ -569,11 +531,10 @@ def run_uninstall(args): project_root = get_project_root() hermes_home = get_hermes_home() + full_flag = bool(getattr(args, "full", False)) if bool(getattr(args, "dry_run", False)): _print_uninstall_dry_run( - project_root=project_root, - hermes_home=hermes_home, - full_uninstall=bool(getattr(args, "full", False))) + project_root=project_root, hermes_home=hermes_home, full_uninstall=full_flag) return # Detect named profiles when uninstalling from the default root — @@ -590,16 +551,14 @@ def run_uninstall(args): # lite/full modes. if bool(getattr(args, "yes", False)): _perform_uninstall( - project_root=project_root, hermes_home=hermes_home, - full_uninstall=bool(getattr(args, "full", False)), remove_profiles=False, - named_profiles=named_profiles, - ) + project_root=project_root, hermes_home=hermes_home, full_uninstall=full_flag, + remove_profiles=False, named_profiles=named_profiles) return print() _print_box("│ ⚕ Hermes Agent Uninstaller │", Colors.MAGENTA) print() - + # Show what will be affected print(color("Current Installation:", Colors.CYAN, Colors.BOLD)) print(f" Code: {project_root}") @@ -625,15 +584,15 @@ def run_uninstall(args): print() print(" 3) " + color("Cancel", Colors.CYAN) + " - Don't uninstall") print() - + choice = _prompt(color("Select option [1/2/3]: ", Colors.BOLD)) if choice is None: return - + if choice in {"3", "c", "cancel", "q", "quit", "n", "no"}: _cancelled() return - + full_uninstall = (choice == "2") # When doing a full uninstall from the default profile, also offer to @@ -662,7 +621,7 @@ def run_uninstall(args): print(color(f" Plus {n_profiles} profile(s): {profile_names}", Colors.RED)) else: print("This will remove the Hermes code but keep your configuration and data.") - + print() if not _confirm_yes("to confirm"): return @@ -700,10 +659,9 @@ def _remove_step(label: str, remove, success_fmt: str, none_msg: str) -> None: """Announce ``label``, run ``remove()``, then log one success line per removed item (or ``none_msg``).""" log_info(label) removed = remove() - if removed: - for item in removed: - log_success(success_fmt.format(item)) - else: + for item in removed: + log_success(success_fmt.format(item)) + if not removed: log_info(none_msg) @@ -735,12 +693,12 @@ def _perform_uninstall( print() print(color("Uninstalling...", Colors.CYAN, Colors.BOLD)) print() - + # 1. Stop and uninstall gateway service + kill standalone processes log_info("Checking for running gateway...") if not uninstall_gateway_service(): log_info("No gateway service or processes found") - + # 2-3b. PATH entries (POSIX rc files, then the Windows User-scope registry), wrapper script, # Windows launchers, node/npm/npx symlinks. Windows notes: hermes_home is %VAR%-expanded so # marker matching runs against fully resolved paths (install.ps1 writes literal @@ -794,7 +752,7 @@ def _perform_uninstall( "Removing Windows installer artifacts (PortableGit, Node, gateway-service)...", lambda: remove_portable_tooling_windows(hermes_home), "Removed {}", "No Windows installer artifacts to remove") - + # 5. Optionally remove ~/.hermes/ data directory (and named profiles) if full_uninstall: # 5a. Stop and remove each named profile's gateway service and alias wrapper. The profile @@ -818,23 +776,29 @@ def _perform_uninstall( print(f" {hermes_home}/") print() print("To reinstall later with your existing settings:") - if windows: - print(color(" iex (irm https://hermes-agent.nousresearch.com/install.ps1)", Colors.DIM)) - else: - print(color(" curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash", Colors.DIM)) + print(color(_REINSTALL_HINT[windows], Colors.DIM)) print() - if windows: - print(color("Open a new terminal (PowerShell / Windows Terminal) to pick up", Colors.YELLOW)) - print(color("the updated User PATH and environment variables.", Colors.YELLOW)) - else: - print(color("Reload your shell to complete the process:", Colors.YELLOW)) - print(" source ~/.bashrc # or ~/.zshrc") + for line, col in _RELOAD_HINT[windows]: + print(color(line, col) if col else line) print() print("Thank you for using Hermes Agent! ⚕") print() +_REINSTALL_HINT = { + True: " iex (irm https://hermes-agent.nousresearch.com/install.ps1)", + False: " curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash", +} +# windows -> [(line, color or None)] +_RELOAD_HINT = { + True: [("Open a new terminal (PowerShell / Windows Terminal) to pick up", Colors.YELLOW), + ("the updated User PATH and environment variables.", Colors.YELLOW)], + False: [("Reload your shell to complete the process:", Colors.YELLOW), + (" source ~/.bashrc # or ~/.zshrc", None)], +} + + class _UninstallArgs: """Lightweight args namespace for the module entrypoint below.""" From a8e8db4fc5b0906c9620a49b7e64f5886b503f99 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:12:29 -0700 Subject: [PATCH 4/5] refactor(hermes_cli): AST-neutral string-literal joins and import-blank squeeze in update_cmd*/uninstall --- hermes_cli/uninstall.py | 18 +++------- hermes_cli/update_abort_recovery.py | 2 -- hermes_cli/update_cmd.py | 55 +++++++---------------------- hermes_cli/update_cmd_config.py | 8 ----- hermes_cli/update_cmd_deps.py | 21 ++--------- 5 files changed, 19 insertions(+), 85 deletions(-) diff --git a/hermes_cli/uninstall.py b/hermes_cli/uninstall.py index c0330cadfc..faf0ebd7f8 100644 --- a/hermes_cli/uninstall.py +++ b/hermes_cli/uninstall.py @@ -105,7 +105,6 @@ def remove_path_from_shell_configs(): new_content = _strip_hermes_path_lines(content) if new_content != content: from utils import atomic_write_text - # This is the user's own shell rc, not a Hermes-owned file, and nothing here backs # it up. A bare write_text() truncates it before the new content lands, so a crash # or SIGINT mid-write leaves an empty/truncated ~/.zshrc -- and the enclosing @@ -387,7 +386,6 @@ def remove_windows_bin_launchers(*, windows: bool | None = None) -> list[Path]: # startup heal stage into this dir. from hermes_cli._install_repair import _WINDOWS_BIN_LAUNCHERS from hermes_constants import get_default_hermes_root - bin_dir = get_default_hermes_root() / "bin" except Exception as e: log_warn(f"Could not locate the managed binary dir: {e}") @@ -449,8 +447,7 @@ def _uninstall_profile(profile) -> None: try: subprocess.run( hermes_invocation + ["gateway", subcmd], capture_output=True, text=True, - encoding='utf-8', errors='replace', timeout=60, check=False, - ) + encoding='utf-8', errors='replace', timeout=60, check=False) except subprocess.TimeoutExpired: log_warn(f" Gateway {subcmd} timed out for '{name}'") except Exception as e: @@ -628,8 +625,7 @@ def run_uninstall(args): _perform_uninstall( project_root=project_root, hermes_home=hermes_home, full_uninstall=full_uninstall, - remove_profiles=remove_profiles, named_profiles=named_profiles, - ) + remove_profiles=remove_profiles, named_profiles=named_profiles) def _print_uninstall_dry_run(*, project_root: Path, hermes_home: Path, full_uninstall: bool) -> None: @@ -788,15 +784,13 @@ def _perform_uninstall( _REINSTALL_HINT = { True: " iex (irm https://hermes-agent.nousresearch.com/install.ps1)", - False: " curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash", -} + False: " curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash"} # windows -> [(line, color or None)] _RELOAD_HINT = { True: [("Open a new terminal (PowerShell / Windows Terminal) to pick up", Colors.YELLOW), ("the updated User PATH and environment variables.", Colors.YELLOW)], False: [("Reload your shell to complete the process:", Colors.YELLOW), - (" source ~/.bashrc # or ~/.zshrc", None)], -} + (" source ~/.bashrc # or ~/.zshrc", None)]} class _UninstallArgs: @@ -817,12 +811,10 @@ def main(argv=None) -> int: from the venv. """ import argparse - parser = argparse.ArgumentParser(prog="python -m hermes_cli.uninstall") parser.add_argument( "--mode", choices=["gui", "lite", "full"], required=True, - help="gui = Chat GUI only; lite = GUI + agent, keep data; full = everything", - ) + help="gui = Chat GUI only; lite = GUI + agent, keep data; full = everything") args = _UninstallArgs(mode=parser.parse_args(argv).mode) (run_gui_uninstall if args.gui else run_uninstall)(args) return 0 diff --git a/hermes_cli/update_abort_recovery.py b/hermes_cli/update_abort_recovery.py index 782537a720..cf03549538 100644 --- a/hermes_cli/update_abort_recovery.py +++ b/hermes_cli/update_abort_recovery.py @@ -59,7 +59,6 @@ def _surviving_pre_update_serve_runtimes(plan) -> list[dict]: return [] try: from hermes_cli.process_identity import ledger_entries - live: dict[int, float | None] = { entry["pid"]: _numeric(entry.get("create_time")) for entry in ledger_entries() @@ -123,7 +122,6 @@ def _recover_gateway_restart_after_abort( Only profiles classified as supervisor-owned by the pre-update inventory are handed off. """ from hermes_cli.update_cmd import _gateway_recovery_partition - candidates, skipped = _gateway_recovery_partition(plan, skip_profiles=skip_profiles) profiles = sorted(candidates) recover_serve = _serve_unit_recovery_available() diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 6f3ffd4174..17bde3c998 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -120,14 +120,12 @@ logger = logging.getLogger(__name__) def _m(): """Lazy ``hermes_cli.main`` handle: keeps main-side test patches effective, import one-way.""" from hermes_cli import main - return main def _updates_config() -> dict: """The ``updates:`` config section (``{}`` when absent/malformed); may raise on config errors.""" from hermes_cli.config import load_config - section = (load_config() or {}).get("updates", {}) return section if isinstance(section, dict) else {} @@ -147,15 +145,13 @@ def _no_prompt_git_kwargs() -> dict: _UPDATE_CRITICAL_FILES = ( "hermes_cli/main.py", "hermes_cli/config.py", "hermes_cli/__init__.py", "hermes_cli/web_server.py", "cli.py", "run_agent.py", "model_tools.py", "toolsets.py", - "hermes_constants.py", -) + "hermes_constants.py") def _record_update_step(step: str, ok: bool, detail: str = "") -> None: """Best-effort ``update_receipt.record_step``; the receipt must never break an update.""" with suppress(Exception): from hermes_cli.update_receipt import record_step - record_step(step, ok, detail) @@ -165,8 +161,7 @@ def _git_run(git_cmd, args, cwd=None, *, check=False, network=False): return subprocess.run( git_cmd + args, cwd=_m().PROJECT_ROOT if cwd is None else cwd, capture_output=True, text=True, encoding="utf-8", errors="replace", check=check, - **(_no_prompt_git_kwargs() if network else {}), - ) + **(_no_prompt_git_kwargs() if network else {})) def _capture_head_sha(git_cmd, cwd) -> str | None: @@ -183,7 +178,6 @@ def _validate_python_files_syntax( """Compile *relpaths* under *root* without writing bytecode into the tree.""" import py_compile import tempfile - root = Path(root) with tempfile.TemporaryDirectory(prefix="hermes-syntax-check-") as tmpdir: for relpath in relpaths: @@ -216,7 +210,6 @@ def _gateway_prompt(prompt_text: str, default: str = "", timeout: float = 300.0) import json as _json import uuid as _uuid from hermes_constants import get_hermes_home # noqa: F811 (deliberate: constants variant) - home = get_hermes_home() prompt_path = home / ".update_prompt.json" response_path = home / ".update_response" @@ -393,20 +386,17 @@ def _format_concurrent_instances_message( "", " Close Hermes Desktop, exit any open `hermes` REPLs, and", " stop the gateway (`hermes gateway stop`) before retrying.", - "", - ] + ""] if matches: pid_args = " ".join(f"/PID {pid}" for pid, _ in matches) lines += [ " If you've already closed everything and these PIDs are", " stale, terminate them directly, then retry the update:", f" taskkill {pid_args} /F", - "", - ] + ""] lines += [ " Override with `hermes update --force` if you've already", - " confirmed those processes will not write to the venv.", - ] + " confirmed those processes will not write to the venv."] return "\n".join(lines) @@ -429,7 +419,6 @@ def _classify_concurrent_instance(pid: int) -> str: return "unknown" from hermes_cli._scan_venv_blockers import _is_pausable_gateway # noqa: PLC0415 - return "gateway" if _is_pausable_gateway(" ".join(cmdline_list or [])) else "non-gateway" @@ -459,8 +448,7 @@ def _run_logged_subprocess(cmd, *, cwd=None, env=None): ``CompletedProcess`` so the caller can surface the output on failure.""" result = subprocess.run( cmd, cwd=cwd, env=env, check=False, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True, encoding="utf-8", errors="replace", - ) + text=True, encoding="utf-8", errors="replace") _log_only_write(result.stdout or "") return result @@ -491,7 +479,6 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False): # Interrupted fetches leave .git/*.lock behind ("File exists" forever); self-heal first. from hermes_cli.gitlock import clear_stale_git_locks, clear_stale_tmp_packs - cleared = clear_stale_git_locks(_m().PROJECT_ROOT) for lock_path in cleared: print(f" (removed stale git lock: {lock_path})") @@ -540,7 +527,6 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False): print("✓ Already up to date.") return from hermes_cli.banner import _github_compare_behind - # counted == 0 means local-ahead, not behind; None means the API could not count. _print_update_check_result(_github_compare_behind(head_sha, target_sha), compare_branch) return @@ -560,7 +546,6 @@ def _print_update_check_result(behind: int | None, compare_branch: str) -> None: else: print(f"⚕ Update available (behind {compare_branch}).") from hermes_cli.config import recommended_update_command - print(f" Run '{recommended_update_command()}' to install.") @@ -580,7 +565,6 @@ def _repair_current_checkout( # "No new commits" != safe interpreter: uv can keep the same CPython patch while # python-build-standalone refreshes the embedded SQLite; keep the boundary hook here too. from hermes_cli.managed_uv import ensure_uv, update_managed_uv - runtime_repairs = [] update_managed_uv(repair_observer=runtime_repairs.append) ensure_uv(repair_observer=runtime_repairs.append) @@ -604,7 +588,6 @@ def _repair_current_checkout( _m()._abort_dependency_sync_if_self_locked(_windows_gateway_resume) _write_update_incomplete_marker() from hermes_cli.managed_uv import ensure_uv - repair_uv = ensure_uv() # Venv gone entirely (repair interrupted after the old one was moved aside): recreate. venv_python_missing = not ( @@ -615,7 +598,6 @@ def _repair_current_checkout( if repair_uv: # Isolated from third-party UV env vars, like the other dependency syncs. from hermes_cli.managed_uv import managed_python_env - repair_prefix, repair_env = [repair_uv, "pip"], managed_python_env() repair_env["VIRTUAL_ENV"] = str(_m().PROJECT_ROOT / "venv") else: @@ -698,7 +680,6 @@ def _reconcile_diverged_checkout(git_cmd, branch: str, pre_pull_sha) -> None: has_common_ancestor = merge_base_result.returncode == 0 and merge_base_result.stdout.strip() if not has_common_ancestor and pre_pull_sha: from datetime import datetime as _dt, timezone - # SHA suffix so two updates in the same second get distinct refs. rescue_ref = ( f"refs/hermes-update-backups/orphan-{branch}-" @@ -936,7 +917,6 @@ def _prepare_checkout_for_update( _git_run(git_cmd, ["rev-parse", "--is-shallow-repository"]).stdout.strip() == "true") if commit_count > 0 and apply_is_shallow: from hermes_cli.banner import _github_compare_behind - head_sha = _git_run(git_cmd, ["rev-parse", "HEAD"]).stdout.strip() target_sha = _git_run(git_cmd, ["rev-parse", f"origin/{branch}"]).stdout.strip() counted = _github_compare_behind(head_sha, target_sha) @@ -961,8 +941,7 @@ def _prepare_checkout_for_update( return _CheckoutPlan( auto_stash_ref=auto_stash_ref, commit_count=commit_count, in_place_update=in_place_update, parked_branch_switched=parked_branch_switched, prompt_for_restore=prompt_for_restore, - switch_block_reason=switch_block_reason, upstream_checked=upstream_checked, - ) + switch_block_reason=switch_block_reason, upstream_checked=upstream_checked) @dataclass @@ -1012,8 +991,7 @@ def _resolve_update_options(args, gateway_mode: bool) -> _UpdateOptions: active_lazy_features=active_lazy_features, active_tool_dependencies=active_tool_dependencies, pre_update_version=pre_update_version, gw_input_fn=gw_input_fn, assume_yes=assume_yes, keep_stash=keep_stash, - switch_branch=switch_branch, discard_local_changes=discard_local_changes, - ) + switch_branch=switch_branch, discard_local_changes=discard_local_changes) def _begin_update_receipt_and_plan(args): @@ -1023,7 +1001,6 @@ def _begin_update_receipt_and_plan(args): # Structured receipt: record what this run discovers/does/skips so silent failures are diagnosable. with _best_effort('Update receipt unavailable: %s'): from hermes_cli.update_receipt import begin_update_receipt - begin_update_receipt() # Plan phase: snapshot runtimes/supervisors/version (read-only; probe failure records @@ -1148,8 +1125,7 @@ def _handle_update_called_process_error( if _called_process_error_is_python_dep_install(e): print( " The git update already finished. Re-downloading the source " - "ZIP cannot fix a dependency install error and would overwrite " - "local files.") + "ZIP cannot fix a dependency install error and would overwrite local files.") if _m()._is_windows(): print(" Retry through the venv interpreter:") print( @@ -1157,7 +1133,6 @@ def _handle_update_called_process_error( '"from hermes_cli.main import main; main()" update --yes') with suppress(Exception): from hermes_cli.update_receipt import finalize_update_receipt - finalize_update_receipt("failed") sys.exit(1) @@ -1217,7 +1192,6 @@ def _finish_already_up_to_date( _write_gateway_update_exit_code(False) with _best_effort('Update receipt finalize (current checkout) failed: %s'): from hermes_cli.update_receipt import finalize_update_receipt - finalize_update_receipt("partial") sys.exit(1) @@ -1246,7 +1220,6 @@ def _cmd_update_impl(args, gateway_mode: bool): _windows_gateway_resume = _m()._pause_windows_gateways_for_update() if _windows_gateway_resume: import atexit as _atexit - _atexit.register(_m()._resume_windows_gateways_after_update, _windows_gateway_resume) # Any venv python still running (typically the Desktop `hermes serve` backend) keeps .pyd @@ -1282,7 +1255,6 @@ def _cmd_update_impl(args, gateway_mode: bool): # Self-heal abandoned .git/*.lock files (crashed fetch) or the fetch fails "File exists". from hermes_cli.gitlock import clear_stale_git_locks, clear_stale_tmp_packs - cleared = clear_stale_git_locks(_m().PROJECT_ROOT) if cleared: print(" (removed stale git lock(s): %s)" % ", ".join(cleared)) @@ -1317,8 +1289,7 @@ def _cmd_update_impl(args, gateway_mode: bool): had_desktop_app_before_update=had_desktop_app_before_update, active_lazy_features=active_lazy_features, active_tool_dependencies=active_tool_dependencies, - _windows_gateway_resume=_windows_gateway_resume, - ) + _windows_gateway_resume=_windows_gateway_resume) return if commit_count > 0: @@ -1356,8 +1327,7 @@ def _cmd_update_impl(args, gateway_mode: bool): _sync_python_dependencies_after_pull( git_cmd, branch, pre_pull_sha, active_lazy_features=active_lazy_features, active_tool_dependencies=active_tool_dependencies, - _windows_gateway_resume=_windows_gateway_resume, - ) + _windows_gateway_resume=_windows_gateway_resume) node_failures = _update_node_dependencies() _m()._build_web_ui(_m().PROJECT_ROOT / "web") @@ -1386,8 +1356,7 @@ def _cmd_update_impl(args, gateway_mode: bool): _verify_fleet_after_update( _restart, _pre_update_plan=_pre_update_plan, _windows_gateway_resume=_windows_gateway_resume, node_failures=node_failures, - update_complete=update_complete, - ) + update_complete=update_complete) except _shim_quarantine_error_type() as e: # Strict quarantine refused BEFORE any installer ran — defer via marker, exit 2, no ZIP. diff --git a/hermes_cli/update_cmd_config.py b/hermes_cli/update_cmd_config.py index ab27f88ac2..cead271dae 100644 --- a/hermes_cli/update_cmd_config.py +++ b/hermes_cli/update_cmd_config.py @@ -24,7 +24,6 @@ def _reload_config_modules() -> None: cleanup sees symbols the update added instead of dying with ImportError. """ import importlib - importlib.invalidate_caches() for mod_name in ( "hermes_cli.config_defaults", @@ -45,7 +44,6 @@ def _run_config_check_fresh() -> tuple: from hermes_cli.update_cmd import _reload_config_modules _reload_config_modules() from hermes_cli.config import check_config_version - return check_config_version() @@ -54,7 +52,6 @@ def _run_migrate_config_fresh(*, interactive: bool = False, quiet: bool = False) from hermes_cli.update_cmd import _reload_config_modules _reload_config_modules() from hermes_cli.config import migrate_config - return migrate_config(interactive=interactive, quiet=quiet) @@ -75,7 +72,6 @@ def _migrate_sibling_profile_configs() -> list[tuple[str, int, int]]: from hermes_constants import ( get_process_hermes_home, reset_hermes_home_override, set_hermes_home_override) from hermes_cli.profiles import _get_profiles_root, _PROFILE_ID_RE - active_home = get_process_hermes_home() root = _get_profiles_root() if not root.is_dir(): @@ -113,7 +109,6 @@ def _restore_snapshot_safety_nets(pre_update_snapshot_id) -> None: # restore from the pre-update snapshot if jobs went missing. try: from hermes_cli.backup import restore_cron_jobs_if_emptied - cron_restore = restore_cron_jobs_if_emptied(pre_update_snapshot_id) if cron_restore: print() @@ -129,7 +124,6 @@ def _restore_snapshot_safety_nets(pre_update_snapshot_id) -> None: # moa:; restore only those protected keys from the same pre-update snapshot. try: from hermes_cli.backup import restore_config_model_settings_if_rewritten - cfg_restore = restore_config_model_settings_if_rewritten(pre_update_snapshot_id) if cfg_restore: print() @@ -144,7 +138,6 @@ def _restore_snapshot_safety_nets(pre_update_snapshot_id) -> None: # Same cron-jobs safety net per sibling profile against ITS OWN pre-update snapshot. with _best_effort('Sibling cron auto-restore check failed: %s'): from hermes_cli.backup import restore_cron_jobs_all_profiles - for _restored in restore_cron_jobs_all_profiles( _LAST_SIBLING_SNAPSHOTS): print() @@ -157,7 +150,6 @@ def _restore_snapshot_safety_nets(pre_update_snapshot_id) -> None: # Same config model-settings safety net for sibling profiles. with _best_effort('Sibling config auto-restore check failed: %s'): from hermes_cli.backup import restore_config_model_settings_all_profiles - for _cfg_restored in restore_config_model_settings_all_profiles( _LAST_SIBLING_SNAPSHOTS): print() diff --git a/hermes_cli/update_cmd_deps.py b/hermes_cli/update_cmd_deps.py index eef2c4232d..6f1b82e0b4 100644 --- a/hermes_cli/update_cmd_deps.py +++ b/hermes_cli/update_cmd_deps.py @@ -67,7 +67,6 @@ def _critical_module_import_failures( from hermes_constants import FIRST_PARTY_MODULE_ROOTS import secrets - marker = f"__HERMES_IMPORT_HEALTH_{secrets.token_hex(16)}__" probe = ( "import importlib, json, sys\n" @@ -103,8 +102,7 @@ def _critical_module_import_failures( interpreter = str(venv_python) result = subprocess.run( [interpreter, "-c", probe], cwd=str(root), capture_output=True, text=True, - encoding="utf-8", errors="replace", timeout=120, - ) + encoding="utf-8", errors="replace", timeout=120) except subprocess.TimeoutExpired: return _probe_failure("TimeoutExpired", "timed out before reporting import health") except (OSError, subprocess.SubprocessError): @@ -197,7 +195,6 @@ def _capture_active_lazy_features() -> list[str]: """Snapshot active lazy backends before a managed runtime is replaced.""" try: from tools import lazy_deps - return lazy_deps.active_features() except Exception as exc: logger.debug("Could not snapshot active lazy features: %s", exc) @@ -208,7 +205,6 @@ def _capture_active_tool_dependencies() -> list[str]: """Snapshot Python dependencies installed explicitly through ``hermes tools``.""" try: from hermes_cli import tools_config - return tools_config.active_restorable_python_tool_dependencies() except Exception as exc: logger.debug("Could not snapshot active Hermes Tools dependencies: %s", exc) @@ -376,7 +372,6 @@ def _refresh_active_memory_provider_dependencies() -> None: """ try: from hermes_cli.config import load_config - cfg = load_config() except Exception as exc: logger.debug("Memory provider refresh skipped (config load failed): %s", exc) @@ -423,7 +418,6 @@ def _install_psutil_android_compat( import tempfile import urllib.request from hermes_cli.psutil_android import PSUTIL_URL, prepare_patched_psutil_sdist - with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) archive = tmp_path / "psutil.tar.gz" @@ -439,7 +433,6 @@ def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None: PATH uv; else wheel-only ``pip install uv`` so the Rust crate is never source-built.""" from hermes_cli.update_cmd import _m from hermes_cli.managed_uv import resolve_uv - existing = resolve_uv() if existing: return existing @@ -596,7 +589,6 @@ def _update_node_dependencies() -> list[str]: # Only a Windows npm reachable from WSL: flag loudly — skipping silently leaves # deps stale, running it would corrupt the tree. from hermes_constants import is_wsl - path_npm = shutil.which("npm") if is_wsl() and path_npm and _m()._is_windows_npm_path(path_npm): print("→ Updating Node.js dependencies...") @@ -609,7 +601,6 @@ def _update_node_dependencies() -> list[str]: return [] from hermes_constants import get_default_hermes_root - # node_modules is shared by every profile on this checkout: one per-checkout cache. shared_hermes_root = get_default_hermes_root() @@ -644,7 +635,6 @@ def _update_node_dependencies() -> list[str]: "--include-workspace-root"] from hermes_constants import with_hermes_node_path - nixos_env = with_hermes_node_path(_m()._nixos_build_env()) # capture_output=False is deliberate: postinstall scripts print download progress and @@ -694,8 +684,7 @@ def _venv_core_imports_healthy() -> tuple[bool, str]: try: result = subprocess.run( [str(venv_python), "-c", check], capture_output=True, text=True, encoding="utf-8", - errors="replace", timeout=60, cwd=_m().PROJECT_ROOT, - ) + errors="replace", timeout=60, cwd=_m().PROJECT_ROOT) except Exception as exc: logger.debug("venv health probe failed to run: %s", exc) return True, "" @@ -730,7 +719,6 @@ def _dependency_sync_would_rewrite(dist_name: str) -> bool | None: from hermes_cli.update_cmd import _m try: from importlib import metadata as _ilmd - installed = _ilmd.version(dist_name) except Exception: return True # not installed → the sync will definitely install it @@ -740,7 +728,6 @@ def _dependency_sync_would_rewrite(dist_name: str) -> bool | None: from packaging.requirements import Requirement from packaging.utils import canonicalize_name from packaging.version import Version - pyproject = _m().PROJECT_ROOT / "pyproject.toml" data = tomllib.loads(pyproject.read_text(encoding="utf-8")) project = data.get("project") or {} @@ -864,7 +851,6 @@ def _rebuild_desktop_after_update( # rebuild window), then surface the tail. Put Hermes-managed Node on PATH: the desktop # updater chain loses shell PATH customizations, so a bare-PATH child hits `node: not found`. from hermes_constants import with_hermes_node_path - build_env = with_hermes_node_path() build_result = _m()._run_logged_subprocess( desktop_build_cmd, cwd=_m().PROJECT_ROOT, env=build_env) @@ -877,7 +863,6 @@ def _rebuild_desktop_after_update( if tail: print(tail) from hermes_constants import display_hermes_home as _dhh - print(f" Full build log: {_dhh()}/logs/update.log") return False print(" ✓ Desktop app up to date") @@ -1008,7 +993,6 @@ def _sync_python_dependencies_after_pull( else: print("→ Updating Python dependencies...") from hermes_cli.managed_uv import ensure_uv, update_managed_uv - # `uv self update` if we already have a managed uv. update_managed_uv() @@ -1023,7 +1007,6 @@ def _sync_python_dependencies_after_pull( if uv_bin: # managed_python_env() isolation so a third-party UV_PYTHON_INSTALL_DIR can't hijack uv. from hermes_cli.managed_uv import managed_python_env - install_prefix, lazy_env = [uv_bin, "pip"], managed_python_env() lazy_env["VIRTUAL_ENV"] = str(_m().PROJECT_ROOT / "venv") termux_note = " → Termux detected: using uv + curated termux-all optional profile..." From 1767ee8119db809cb8f7303c6ac4bb799ff4c7c1 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:28:27 -0700 Subject: [PATCH 5/5] refactor(hermes_cli): compact docstrings/comments in update_cmd*, update_abort_recovery, uninstall (keep every WHY) --- hermes_cli/uninstall.py | 175 ++++++++++------------------ hermes_cli/update_abort_recovery.py | 81 +++++-------- hermes_cli/update_cmd.py | 74 +++++------- hermes_cli/update_cmd_config.py | 47 +++----- hermes_cli/update_cmd_deps.py | 99 ++++++---------- 5 files changed, 172 insertions(+), 304 deletions(-) diff --git a/hermes_cli/uninstall.py b/hermes_cli/uninstall.py index faf0ebd7f8..fa14d4bf0d 100644 --- a/hermes_cli/uninstall.py +++ b/hermes_cli/uninstall.py @@ -52,10 +52,8 @@ def _confirm_yes(text: str) -> bool: def _remove_each(candidates, remove) -> list: - """Run ``remove(path)`` for each candidate; collect those it reports removed (truthy). - - Failures are downgraded to the shared ``Could not remove : `` warning. - """ + """Run ``remove(path)`` per candidate, collecting the ones it reports removed; failures become + the shared ``Could not remove : `` warning.""" removed = [] for path in candidates: try: @@ -75,7 +73,8 @@ _SHELL_RC_NAMES = (".bashrc", ".bash_profile", ".profile", ".zshrc", ".zprofile" def _strip_hermes_path_lines(content: str) -> str: - """Drop the ``# Hermes Agent`` marker (+ its PATH line) and any hermes PATH line; squash blank runs.""" + """Drop the ``# Hermes Agent`` marker (+ its PATH line) and any hermes PATH line; squash blank + runs.""" new_lines = [] skip_next = False for line in content.split('\n'): @@ -105,13 +104,10 @@ def remove_path_from_shell_configs(): new_content = _strip_hermes_path_lines(content) if new_content != content: from utils import atomic_write_text - # This is the user's own shell rc, not a Hermes-owned file, and nothing here backs - # it up. A bare write_text() truncates it before the new content lands, so a crash - # or SIGINT mid-write leaves an empty/truncated ~/.zshrc -- and the enclosing - # `except Exception` downgrades that to a warning, so the next login just starts a - # bare shell. atomic_replace also resolves a symlinked rc file (dotfiles repos keep - # the symlink). preserve_mode keeps the rc's permission bits (normally 0644) and - # owner (sudo-run uninstalls) instead of mkstemp's 0600/root. + # The user's own rc, never backed up: a bare write_text() truncates before the new + # content lands, and a crash mid-write (downgraded to a warning below) would leave + # an empty ~/.zshrc. Atomic replace also follows a symlinked rc (dotfiles repos); + # preserve_mode keeps its 0644 bits and owner (sudo-run uninstalls). atomic_write_text(config_path, new_content, preserve_mode=True) removed_from.append(config_path) except Exception as e: @@ -148,15 +144,9 @@ def _node_symlink_candidate_dirs() -> "list[Path]": def remove_node_symlinks(hermes_home: Path) -> list: - """Remove the node/npm/npx symlinks the installer placed on PATH. - - - ``/usr/local/bin/`` on root FHS installs (Linux, uid 0) - ``$PREFIX/bin/`` on Termux - - ``~/.local/bin/`` otherwise (the common non-root case) - - We check all candidate directories so that uninstall works regardless of how the install was - done (e.g. a root FHS install that placed links in ``/usr/local/bin``, or an older install that - used ``~/.local/bin`` before the FHS fix). - """ + """Remove the node/npm/npx symlinks the installer placed on PATH. Every candidate dir is + checked (``/usr/local/bin`` root FHS, ``$PREFIX/bin`` Termux, ``~/.local/bin`` otherwise / older + installs) so uninstall works regardless of how the install was done.""" node_dir = (hermes_home / "node").resolve() def _unlink_ours(link: Path) -> bool: @@ -176,12 +166,8 @@ def remove_node_symlinks(hermes_home: Path) -> list: def uninstall_gateway_service(): - """Stop/uninstall the gateway service on every platform and kill standalone gateways. - - Delegates to the gateway module: systemd user+system units (Linux), launchd (macOS), - Scheduled Task + Startup folder (Windows), plus standalone ``hermes gateway run`` processes. - Termux/Android has no systemd, so only the process kill applies there. - """ + """Kill standalone gateways, then remove the per-platform service (systemd user+system / + launchd / Scheduled Task + Startup folder). Termux/Android has neither: only the kill applies.""" import platform stopped_something = False @@ -249,12 +235,8 @@ def _remove_launchd_gateway() -> bool: def _remove_windows_gateway() -> bool: - """Windows: uninstall Scheduled Task + Startup-folder entry. - - The gateway_windows module already knows how to locate and remove both code paths (schtasks - /Delete + .cmd unlink) and how to stop any running detached pythonw gateway process. We call - into it so the uninstall logic stays in exactly one place. - """ + """Windows: uninstall Scheduled Task + Startup-folder entry via ``gateway_windows`` (it owns + schtasks /Delete, the .cmd unlink and stopping the detached pythonw gateway).""" from hermes_cli import gateway_windows if not any(probe() for probe in ( gateway_windows.is_installed, gateway_windows.is_task_registered, gateway_windows.is_startup_entry_installed, @@ -290,12 +272,9 @@ _GATEWAY_SERVICE_REMOVERS = { def _hermes_path_markers(hermes_home: Path, *, include_managed_bin: bool = False) -> list[str]: - """Path-entry substrings that identify Hermes-owned User-PATH entries. - - ``include_managed_bin`` adds the managed binary dir (``\bin``, holding the hermes - launchers and the managed uv) — only wanted when that dir is about to be deleted (full uninstall - from the default root), so a keep-data uninstall leaves the still-working managed uv resolvable. - """ + """Prefixes identifying Hermes-owned User-PATH entries. ``include_managed_bin`` adds + ``\bin`` (launchers + managed uv) — only when that dir is about to be deleted (full + uninstall from the default root), so a keep-data uninstall keeps the working uv resolvable.""" root = str(hermes_home).rstrip("\\/") # Match on prefix so sub-entries (git\cmd, git\bin, git\usr\bin, node, etc.) all get swept. subs = ("hermes-agent", "git", "node", "venv") + (("bin",) if include_managed_bin else ()) @@ -342,11 +321,9 @@ def remove_hermes_env_vars_windows() -> list[str]: def _edit_user_environment(edit, *, warn_label: str) -> list[str]: - """Open HKCU\\Environment read/write and run ``edit(winreg, key, removed)``. - - Returns whatever ``edit`` appended to ``removed`` — even when a later registry call raised, so - callers report exactly what was touched. ``[]`` off-Windows (no ``winreg``). - """ + """Open HKCU\\Environment read/write and run ``edit(winreg, key, removed)``. Returns what + ``edit`` appended to ``removed`` even if a later registry call raised (callers report exactly + what was touched); ``[]`` off-Windows (no ``winreg``).""" removed: list[str] = [] try: import winreg @@ -370,13 +347,10 @@ def remove_portable_tooling_windows(hermes_home: Path) -> list[Path]: def remove_windows_bin_launchers(*, windows: bool | None = None) -> list[Path]: - """Delete the ``hermes`` launchers install.ps1 staged in the managed ``bin`` dir. - - Every uninstall mode deletes the checkout, so launchers pointing at ``/venv`` - would dangle and error, which reads worse than command-not-found; the managed uv is kept for - keep-data reinstalls. A launcher that is this process's own trampoline is locked against - deletion but not rename, so it is renamed aside with a non-executable suffix instead. - """ + """Delete the ``hermes`` launchers install.ps1 staged in the managed ``bin`` dir: every mode + deletes the checkout, so a launcher pointing at ``/venv`` would dangle (worse than + command-not-found); the managed uv stays for keep-data reinstalls. Our own running trampoline is + locked against deletion but not rename, so it is renamed aside with a non-executable suffix.""" if windows is None: windows = _is_windows() if not windows: @@ -431,17 +405,13 @@ def _discover_named_profiles(): def _uninstall_profile(profile) -> None: - """Fully uninstall a named profile: stop its gateway, remove its alias, wipe its home. - - Shells out to ``hermes -p gateway stop|uninstall`` because service names and unit/ - plist paths derive from the current HERMES_HOME and can't easily be switched in-process. - """ + """Fully uninstall a named profile: stop its gateway, remove its alias, wipe its home. Shells + out to ``hermes -p gateway stop|uninstall`` because service names / unit paths derive + from the current HERMES_HOME and can't be switched in-process.""" name = profile.name log_info(f"Uninstalling profile '{name}'...") - # 1. Stop and remove this profile's gateway service. - # Use `python -m hermes_cli.main` so we don't depend on a `hermes` - # wrapper that may be half-removed mid-uninstall. + # 1. Gateway service, via `python -m hermes_cli.main` (the `hermes` wrapper may be half-gone). hermes_invocation = [sys.executable, "-m", "hermes_cli.main", "--profile", name] for subcmd in ("stop", "uninstall"): try: @@ -467,12 +437,8 @@ def _uninstall_profile(profile) -> None: def run_gui_uninstall(args): - """GUI-only uninstall: remove the Chat GUI, leave the agent + data intact. - - Mirrors ``hermes uninstall --gui``. Removes the desktop app's built artifacts, the packaged app - bundle (best-effort), and the Electron userData dir — nothing under ``$HERMES_HOME`` - config/sessions/.env, and never the Python agent or its venv. - """ + """``hermes uninstall --gui``: remove the desktop app's built artifacts, packaged bundle + (best-effort) and Electron userData — never config/sessions/.env, the agent or its venv.""" from hermes_cli.gui_uninstall import ( agent_is_installed, gui_install_summary, uninstall_gui) @@ -520,11 +486,8 @@ def run_gui_uninstall(args): def run_uninstall(args): - """Run the uninstall process. - - Full uninstall removes code and ``~/.hermes/``; keep-data removes only the code so the - configs, data and logs survive a reinstall. - """ + """Interactive/``--yes`` uninstall: full removes code and ``~/.hermes/``; keep-data removes only + the code so configs, data and logs survive a reinstall.""" project_root = get_project_root() hermes_home = get_hermes_home() @@ -534,18 +497,13 @@ def run_uninstall(args): project_root=project_root, hermes_home=hermes_home, full_uninstall=full_flag) return - # Detect named profiles when uninstalling from the default root — - # offer to clean them up too instead of leaving zombie HERMES_HOMEs - # and systemd units behind. + # Named profiles (only when uninstalling from the default root) are offered for cleanup too, + # instead of leaving zombie HERMES_HOMEs and systemd units behind. is_default_profile = _is_default_hermes_home(hermes_home) named_profiles = _discover_named_profiles() if is_default_profile else [] - # Non-interactive fast path (``--yes``): no prompts. ``--full`` selects a - # full wipe (code + ~/.hermes data); otherwise keep-data. Named profiles - # are NOT auto-removed here — that's a destructive, surprising default for - # an unattended run, so it stays opt-in to the interactive flow. This is - # the path the desktop app's detached cleanup script uses for its - # lite/full modes. + # ``--yes`` (the desktop app's detached cleanup script): no prompts; ``--full`` = full wipe. + # Named profiles are NOT auto-removed here — too destructive a default for an unattended run. if bool(getattr(args, "yes", False)): _perform_uninstall( project_root=project_root, hermes_home=hermes_home, full_uninstall=full_flag, @@ -592,10 +550,8 @@ def run_uninstall(args): full_uninstall = (choice == "2") - # When doing a full uninstall from the default profile, also offer to - # remove any named profiles — stopping their gateway services, unlinking - # their alias wrappers, and wiping their HERMES_HOME dirs. Otherwise - # those leave zombie services and data behind. + # Full uninstall from the default profile: offer to remove named profiles too (gateway + # services, alias wrappers, HERMES_HOME dirs) — otherwise they leave zombie services behind. remove_profiles = False n_profiles = len(named_profiles) profile_names = ", ".join(p.name for p in named_profiles) @@ -652,7 +608,8 @@ def _print_uninstall_dry_run(*, project_root: Path, hermes_home: Path, full_unin def _remove_step(label: str, remove, success_fmt: str, none_msg: str) -> None: - """Announce ``label``, run ``remove()``, then log one success line per removed item (or ``none_msg``).""" + """Announce ``label``, run ``remove()``, then log one success line per removed item (or + ``none_msg``).""" log_info(label) removed = remove() for item in removed: @@ -680,12 +637,9 @@ def _perform_uninstall( full_uninstall: bool, remove_profiles: bool, named_profiles: list) -> None: - """Execute the uninstall steps; shared by the interactive and ``--yes`` paths. - - Order: stop gateway -> strip PATH (rc files + Windows registry) -> remove wrapper and node - symlinks -> remove desktop Chat GUI artifacts -> delete the checkout -> (Windows) remove - PortableGit/Node -> optionally wipe ``$HERMES_HOME`` and named profiles. - """ + """The uninstall steps shared by the interactive and ``--yes`` paths: stop gateway -> strip PATH + (rc files + Windows registry) -> wrapper/launchers/node symlinks -> Chat GUI artifacts -> delete + the checkout -> (Windows) PortableGit/Node -> optionally ``$HERMES_HOME`` and named profiles.""" print() print(color("Uninstalling...", Colors.CYAN, Colors.BOLD)) print() @@ -695,15 +649,12 @@ def _perform_uninstall( if not uninstall_gateway_service(): log_info("No gateway service or processes found") - # 2-3b. PATH entries (POSIX rc files, then the Windows User-scope registry), wrapper script, - # Windows launchers, node/npm/npx symlinks. Windows notes: hermes_home is %VAR%-expanded so - # marker matching runs against fully resolved paths (install.ps1 writes literal - # C:\Users\\AppData\Local\hermes\git\cmd, not %LOCALAPPDATA%); the managed binary dir - # (hermes\bin: launchers + managed uv) leaves the PATH only when the full wipe below is - # about to delete it, so keep-data mode keeps the still-working uv resolvable; the launchers - # themselves always go — both modes delete the checkout, so a surviving launcher would - # dangle and error, worse than command-not-found. Symlinks are removed only when they still - # point into this Hermes home's node dir (never clobber nvm / user-managed Node). + # 2-3b. PATH entries (rc files, then the Windows User registry), wrapper, Windows launchers, + # node symlinks. Windows: hermes_home is %VAR%-expanded because install.ps1 writes literal + # C:\Users\\AppData\Local\hermes\git\cmd; hermes\bin (launchers + managed uv) leaves the + # PATH only when the full wipe below deletes it (keep-data keeps uv resolvable), while the + # launchers themselves always go (see remove_windows_bin_launchers). Symlinks go only when + # they still point into this home's node dir (never clobber nvm / user-managed Node). windows = _is_windows() sweep_managed_bin = windows and full_uninstall and _is_default_hermes_home(hermes_home) for on_this_platform, label, remove, success_fmt, none_msg in ( @@ -724,11 +675,9 @@ def _perform_uninstall( if on_this_platform: _remove_step(label, remove, success_fmt, none_msg) - # 3c. Desktop Chat GUI artifacts (built renderer/release, node_modules, packaged app bundle, - # Electron userData). Both CLI flows remove the agent code, so the GUI — just another - # consumer of the same checkout — goes with it. uninstall_gui() never touches config / - # sessions / .env, so it's safe in keep-data mode; the packaged app + Electron userData - # live OUTSIDE HERMES_HOME, so the full-uninstall rmtree below would not reach them. + # 3c. Desktop Chat GUI artifacts: both flows remove the agent code, so the GUI goes with it. + # uninstall_gui() never touches config/sessions/.env (safe in keep-data mode); the packaged + # app + Electron userData live OUTSIDE HERMES_HOME, so the rmtree below wouldn't reach them. log_info("Removing desktop Chat GUI artifacts...") try: from hermes_cli.gui_uninstall import uninstall_gui @@ -741,8 +690,8 @@ def _perform_uninstall( log_info("Removing installation directory...") _rmtree_step(project_root) - # 4b. Windows-only installer artifacts that are NOT user data (PortableGit, bundled Node, - # gateway-service dir): install tooling under HERMES_HOME, safe to remove in keep-data mode. + # 4b. Windows installer tooling under HERMES_HOME (PortableGit, Node, gateway-service) is not + # user data: safe to remove in keep-data mode too. if windows: _remove_step( "Removing Windows installer artifacts (PortableGit, Node, gateway-service)...", @@ -751,9 +700,8 @@ def _perform_uninstall( # 5. Optionally remove ~/.hermes/ data directory (and named profiles) if full_uninstall: - # 5a. Stop and remove each named profile's gateway service and alias wrapper. The profile - # HERMES_HOME dirs live under ``/profiles//`` and are swept by the rmtree - # below, but services + alias scripts live OUTSIDE the default root. + # 5a. Named profiles' homes live under /profiles/ (swept by the rmtree below), + # but their services + alias scripts live OUTSIDE the default root. if remove_profiles: for prof in named_profiles: _uninstall_profile(prof) @@ -804,12 +752,9 @@ class _UninstallArgs: def main(argv=None) -> int: - """Module entrypoint: ``python -m hermes_cli.uninstall --mode ``. - - This module imports only stdlib + ``hermes_constants`` + ``hermes_cli.colors`` (and lazily - ``hermes_cli.gui_uninstall``), so it runs fine under a bare system Python with no site-packages - from the venv. - """ + """``python -m hermes_cli.uninstall --mode ``. Imports only stdlib + + ``hermes_constants`` + ``hermes_cli.colors`` (lazily ``gui_uninstall``), so it runs under a + bare system Python without the venv.""" import argparse parser = argparse.ArgumentParser(prog="python -m hermes_cli.uninstall") parser.add_argument( diff --git a/hermes_cli/update_abort_recovery.py b/hermes_cli/update_abort_recovery.py index cf03549538..1990148b65 100644 --- a/hermes_cli/update_abort_recovery.py +++ b/hermes_cli/update_abort_recovery.py @@ -1,11 +1,7 @@ -"""Fresh-process recovery after the update's in-process restart phase aborts. - -``hermes update`` performs its fleet restart in the interpreter that started before ``git pull``. - -It is deliberately a separate owner from ``update_cmd``: the abort path has its own vocabulary -(``verified`` / ``relaunch_attempted`` / ``failed``, serve units, survivors) and its own fail-closed -contract, and the update monolith must not grow another authority surface (review on #96235). -""" +"""Fresh-process recovery after the update's in-process restart phase aborts (the fleet restart +runs in the interpreter that started before ``git pull``). Deliberately a separate owner from +``update_cmd``: its own vocabulary (``verified`` / ``relaunch_attempted`` / ``failed``, serve units, +survivors) and its own fail-closed contract.""" from __future__ import annotations @@ -25,17 +21,11 @@ def _serve_unit_recovery_available() -> bool: def _surviving_pre_update_serve_runtimes(plan) -> list[dict]: - """Pre-update serve/dashboard runtimes that are STILL the same process. - - Identity is the process incarnation ``(pid, create_time)``, never the PID alone. - ``ledger_entries()`` re-verifies that pair on every read and prunes anything that is gone, so a - live entry is a real process — but the numeric PID can be reused, and a serve that was restarted - correctly can come back on the same number. - - Anything still here after the recovery pass is a live runtime on the pre-update code generation, - which is precisely the unsafe state #92145 reports. Fail closed on missing evidence: an - unreadable ledger, or an incarnation neither side can produce, counts the runtime as surviving. - """ + """Pre-update serve/dashboard runtimes that are STILL the same process — i.e. live on the + pre-update code generation. Identity is the incarnation ``(pid, create_time)``, never the PID + alone: ``ledger_entries()`` prunes dead entries, but a correctly restarted serve can come back + on the same number. Fail closed on missing evidence (unreadable ledger, no incarnation on either + side): the runtime counts as surviving.""" planned: dict[int, dict] = {} try: for runtime in getattr(plan, "runtimes", ()) or (): @@ -93,11 +83,8 @@ def _without_incarnation(row: dict) -> dict: def _qualified_serve_skips(skip_units) -> list[dict]: - """Scope-qualify the units the aborted phase already settled. - - ``restarted_scoped_units`` records ``/`` because ``hermes-serve.service`` can exist - in BOTH the user and the system manager, and those are two different processes. - """ + """Scope-qualify the units the aborted phase already settled: ``/`` because + ``hermes-serve.service`` can exist in BOTH the user and system manager (two processes).""" rows: list[dict] = [] for entry in sorted(skip_units or ()): scope, sep, unit = str(entry).partition("/") @@ -114,13 +101,8 @@ def _recover_gateway_restart_after_abort( gateway_mode: bool, skip_profiles: set[str] | None = None, skip_units: set[str] | None = None) -> dict[str, list]: - """Retry supervised gateway restarts from a clean Python process. - - ``hermes update`` normally performs the fleet restart in the interpreter that started before - ``git pull``. - - Only profiles classified as supervisor-owned by the pre-update inventory are handed off. - """ + """Retry supervised gateway restarts from a clean Python process (the in-process restart ran + in the pre-``git pull`` interpreter). Only inventory-classified supervisor-owned profiles.""" from hermes_cli.update_cmd import _gateway_recovery_partition candidates, skipped = _gateway_recovery_partition(plan, skip_profiles=skip_profiles) profiles = sorted(candidates) @@ -148,11 +130,9 @@ def _recover_gateway_restart_after_abort( for marker in ("_HERMES_GATEWAY", "HERMES_GATEWAY", "HERMES_GATEWAY_MODE"): env.pop(marker, None) - # A gateway-triggered update may run inside the gateway's systemd cgroup. - # Put the recovery process in a transient user scope before it asks systemd - # to restart that gateway, otherwise KillMode can terminate the recovery - # process together with the old service. If systemd-run is unavailable, - # fail closed rather than pretending the in-cgroup child is independent. + # A gateway-triggered update may run inside the gateway's systemd cgroup: put the recovery in a + # transient user scope or KillMode terminates it with the old service. No systemd-run -> fail + # closed rather than pretend the in-cgroup child is independent. if gateway_mode and sys.platform == "linux": systemd_run = shutil.which("systemd-run") if not systemd_run: @@ -173,9 +153,8 @@ def _recover_gateway_restart_after_abort( "errors": "replace", "check": False, "env": env, - # Gateway profiles run sequentially at up to 90s each; the serve pass - # adds its own restart + settle budget on top, so give the child room - # for both rather than killing a recovery that was working. + # Gateway profiles run sequentially at up to 90s each, plus the serve pass's own + # restart + settle budget — don't kill a recovery that was working. "timeout": max(180, 30 + 90 * len(profiles) + (150 if recover_serve else 0))} if sys.platform == "win32": kwargs["creationflags"] = ( @@ -214,8 +193,7 @@ def _recover_gateway_restart_after_abort( serve_units = { "verified": sorted(raw_serve["verified"]), "failed": sorted(raw_serve["failed"])} elif recover_serve: - # An unreadable serve block cannot be read as "nothing to do": the - # units it describes are exactly the ones that host tui_gateway and + # An unreadable serve block is not "nothing to do": those units host tui_gateway and # may still be serving the pre-update generation. logger.warning("Fresh recovery returned an invalid serve-unit result") serve_units = {"verified": [], "failed": [""]} @@ -245,13 +223,10 @@ def _recover_gateway_restart_after_abort( def _warn_stale_serve_runtimes(rows) -> None: - """Name the serve/dashboard processes that survived on pre-update code. - - ``hermes serve`` hosts ``tui_gateway.server``; if its unit was never restarted it keeps the - pre-pull ``sys.modules`` graph, every chat turn fails with an ``ImportError`` for a symbol - that imports fine on disk, and no gateway row reveals the culprit. Print the PIDs and the - exact command that fixes it. - """ + """Name the serve/dashboard processes still on pre-update code: ``hermes serve`` hosts + ``tui_gateway.server``, and an un-restarted unit keeps the pre-pull ``sys.modules`` graph so + every chat turn fails with an ``ImportError`` no gateway row explains. Print PIDs + the fixing + command.""" if not rows: return print( @@ -271,13 +246,9 @@ def _warn_stale_serve_runtimes(rows) -> None: def _abort_recovery_is_complete( *, planned_gateway_profiles, covered_gateway_profiles, recovery_result, stale_runtime_rows ) -> bool: - """May a fresh-process recovery clear the incomplete flag? - - Only when EVERY inventoried runtime family is accounted for. - - ``planned_gateway_profiles`` being empty is deliberately NOT completeness: with no gateway leg - to prove, the caller's own fail-closed contract (``_restart_phase_failure_is_incomplete``) plus - the stale-runtime rows decide the outcome. + """May a fresh-process recovery clear the incomplete flag? Only when EVERY inventoried runtime + family is accounted for. Empty ``planned_gateway_profiles`` is deliberately NOT completeness: + with no gateway leg to prove, ``_restart_phase_failure_is_incomplete`` + the stale rows decide. """ if not planned_gateway_profiles: return False diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 17bde3c998..d61d12d199 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -1,10 +1,9 @@ """Hermes update pipeline: dispatchers (``_cmd_update_impl``/``_cmd_update_check``) + git plumbing. Each concern lives in ``update_cmd_.py`` and is re-imported here so -``hermes_cli.update_cmd.`` keeps resolving (and stays monkeypatchable). -``_m()`` is the lazy ``hermes_cli.main`` handle kept for main-side test patches. -Imports are one-way: main -> update_cmd -> update_cmd_* (never the reverse at -import time; ``_m()`` resolves at call time, so no cycle). +``hermes_cli.update_cmd.`` keeps resolving (and stays monkeypatchable). Imports are +one-way: main -> update_cmd -> update_cmd_*; ``_m()`` resolves ``hermes_cli.main`` at call +time (no cycle, main-side test patches stay effective). """ import logging @@ -18,18 +17,15 @@ import time as _time from dataclasses import dataclass from pathlib import Path -# Re-exported: update_cmd_* modules import lazily from here so patches stick. -from hermes_cli.config import get_hermes_home # noqa: F401 +from hermes_cli.config import get_hermes_home # noqa: F401 (re-exported; patched via update_cmd) from hermes_cli.update_cmd_common import _best_effort from hermes_constants import get_default_hermes_root, venv_python_path -# Re-exported: main and the split modules address these via update_cmd (tests patch here). +# Re-exports: every split-module name stays reachable (and monkeypatchable) as update_cmd.. from hermes_cli.update_abort_recovery import ( # noqa: F401 _abort_recovery_is_complete, _qualified_serve_skips, _recover_gateway_restart_after_abort, _serve_unit_recovery_available, _surviving_pre_update_serve_runtimes, _warn_stale_serve_runtimes) -# Re-exports from the split modules: every moved name stays reachable (and monkeypatchable) -# as ``hermes_cli.update_cmd.``; the split modules import origin-internal names lazily. from hermes_cli.update_cmd_windows import ( # noqa: F401 _HOLDER_VALUE_FLAGS_FALLBACK, _clear_windows_venv_holders_or_exit, _cold_start_windows_gateway_after_update, _desktop_owns_gateway_lifecycle, @@ -131,17 +127,17 @@ def _updates_config() -> dict: def _no_prompt_git_kwargs() -> dict: - """``subprocess.run`` kwargs for network git calls: GitHub answers anonymous fetches with - 401 during outages and git would then block forever on ``Username for ...``; disable only - the *prompt* (credential helpers/askpass still run) so it fails fast into ``_classify_fetch_failure``.""" + """``subprocess.run`` kwargs for network git: GitHub answers anonymous fetches with 401 during + outages and git would block forever on ``Username for ...``; disable only the *prompt* + (credential helpers/askpass still run) so it fails fast into ``_classify_fetch_failure``.""" env = dict(os.environ) env["GIT_TERMINAL_PROMPT"] = "0" env["GCM_INTERACTIVE"] = "Never" return {"stdin": subprocess.DEVNULL, "env": env} -# CLI-startup imports (+ web_server.py, the desktop backend a fresh Windows install -# launches) that must parse post-update; the syntax guard auto-rolls-back on failure. +# CLI-startup files (+ web_server.py, launched by a fresh Windows Desktop install) that must +# parse post-update; the syntax guard rolls back when one doesn't. _UPDATE_CRITICAL_FILES = ( "hermes_cli/main.py", "hermes_cli/config.py", "hermes_cli/__init__.py", "hermes_cli/web_server.py", "cli.py", "run_agent.py", "model_tools.py", "toolsets.py", @@ -195,12 +191,9 @@ def _validate_python_files_syntax( def _validate_critical_files_syntax(root) -> tuple[bool, str | None, str | None]: - """Compile ``_UPDATE_CRITICAL_FILES`` -> ``(ok, failing_path, error_message)``. - - A syntax error there means the CLI can't bootstrap, so validate post-pull and - auto-roll-back. The .pyc goes to a temp dir, not ``__pycache__/``: avoids racing - concurrent test workers and leaving a stale pyc for a different interpreter. - """ + """Compile ``_UPDATE_CRITICAL_FILES`` -> ``(ok, failing_path, error_message)``. The .pyc goes + to a temp dir, not ``__pycache__/`` (no race with test workers, no stale pyc for another + interpreter).""" return _validate_python_files_syntax(root, _UPDATE_CRITICAL_FILES) @@ -401,12 +394,9 @@ def _format_concurrent_instances_message( def _classify_concurrent_instance(pid: int) -> str: - """Classify ``pid`` as "gateway" / "non-gateway" / "unknown" (psutil can't read it). - - Uses ``_is_pausable_gateway``, the same matcher as the Desktop preflight exemption and - venv-holder guard, so "gateway" is exactly the set the downstream pause/restart machinery - stops. The gate treats "unknown" as non-gateway (better block than proceed blind). - """ + """Classify ``pid`` as "gateway" / "non-gateway" / "unknown" (psutil can't read it). Uses + ``_is_pausable_gateway`` (same matcher as the Desktop preflight and venv-holder guard) so + "gateway" is exactly what the pause/restart machinery stops; "unknown" gates as non-gateway.""" try: import psutil # noqa: PLC0415 except Exception: @@ -424,8 +414,8 @@ def _classify_concurrent_instance(pid: int) -> str: def _filter_non_gateway_concurrent_instances( matches: list[tuple[int, str]]) -> list[tuple[int, str]]: - """Drop gateway matches (the pause + post-update restart machinery handles them); - anything else (TUI, Desktop backend child, another REPL) has no pause path, so the gate aborts.""" + """Drop gateway matches (the pause + post-update restart machinery handles them); anything else + (TUI, Desktop backend child, another REPL) has no pause path, so the gate aborts.""" return [(pid, name) for pid, name in matches if _classify_concurrent_instance(pid) != "gateway"] @@ -651,8 +641,8 @@ def _repair_current_checkout( def _reconcile_diverged_checkout(git_cmd, branch: str, pre_pull_sha) -> None: - """Fast-forward failed: merge on a custom branch (local commits survive) or reset --hard on - the same branch (rescue ref first when histories share no ancestor). ``sys.exit(1)`` on failure.""" + """Fast-forward failed: merge on a custom branch (local commits survive) or reset --hard on the + same branch (rescue ref first when histories share no ancestor). ``sys.exit(1)`` on failure.""" # Diverged. A custom branch (local commits atop origin/) also can't ff, # and reset --hard would discard that work: merge instead, stop on conflict. _cur_branch = (_git_run(git_cmd, ["branch", "--show-current"]).stdout or "").strip() @@ -807,19 +797,16 @@ class _CheckoutPlan: def _apply_parked_branch_guard( git_cmd, branch, current_branch, *, switch_branch, _windows_gateway_resume ) -> tuple[bool, bool, "str | None"]: - """Decide how a checkout parked on another branch is brought to *branch*. + """Decide how a checkout parked on another branch is brought to *branch* (stash-switch-pull- + switch-back used to "update" main while the running code stayed behind). - Returns ``(parked_branch_switched, in_place_update, switch_block_reason)``; ``sys.exit(1)`` - when the branch is dirty/unverifiable (code update SKIPPED) or the target is missing. + By branch contents + updates.parked_branch_strategy: fully merged -> switch back; + unmerged -> "switch" (default; loud "kept" notice) or "update_in_place" (merge origin/ + INTO the branch, checkout never moves; --switch-branch overrides once); dirty/unverifiable -> + touch nothing, warn, ``sys.exit(1)`` with the code update SKIPPED (also when the target is + missing). Returns ``(parked_branch_switched, in_place_update, switch_block_reason)``. """ switch_block_reason = None # only meaningful when parked_branch_switched - # Parked-branch guard (stash-switch-pull-switch-back used to "update" main while the - # running code stayed behind). By branch contents + updates.parked_branch_strategy: - # fully merged -> switch back to the target. - # unmerged: N -> "switch" (default): switch anyway with a loud "kept" notice; - # "update_in_place": merge origin/ INTO the branch (checkout - # never moves). --switch-branch overrides for one run. - # anything else -> dirty/unverifiable/opted out: touch nothing, warn, mark SKIPPED, stop. parked_branch_switched = False in_place_update = False if current_branch != branch and current_branch != "HEAD": @@ -1228,10 +1215,9 @@ def _cmd_update_impl(args, gateway_mode: bool): if _m()._is_windows() and not getattr(args, "force_venv", False): _clear_windows_venv_holders_or_exit(args, gateway_mode, _windows_gateway_resume) - # Self-lock deferral (this process IS the venv python and can't rewrite its own mapped - # .pyd) deliberately does NOT run here: pre-fetch it stranded users on the OLD checkout - # and eager cryptography imports made every Windows update an exit-2 loop. It runs via - # _abort_dependency_sync_if_self_locked() right before the dependency sync instead. + # Self-lock deferral deliberately does NOT run here (pre-fetch it stranded users on the OLD + # checkout and made every Windows update an exit-2 loop); it runs right before the + # dependency sync via _abort_dependency_sync_if_self_locked(). # After every fail-closed venv guard, before either path can remove the release tree. desktop_dir = _m().PROJECT_ROOT / "apps" / "desktop" diff --git a/hermes_cli/update_cmd_config.py b/hermes_cli/update_cmd_config.py index cead271dae..0ca4d06206 100644 --- a/hermes_cli/update_cmd_config.py +++ b/hermes_cli/update_cmd_config.py @@ -1,8 +1,6 @@ """Post-``hermes update`` config-schema migration for the active profile and every sibling. - -Split out of ``update_cmd.py``; names are re-imported there so ``hermes_cli.update_cmd.`` -still resolves/monkeypatches. Origin helpers are imported lazily per function (no cycle; patches hold). -""" +Names are re-imported by ``update_cmd`` (``hermes_cli.update_cmd.`` resolves/monkeypatches); +origin helpers are imported lazily.""" import logging import sys @@ -15,14 +13,10 @@ logger = logging.getLogger("hermes_cli.update_cmd") def _reload_config_modules() -> None: - """Force-reload modules from disk after git pull. - - ``hermes update`` runs in the PRE-pull process, so cached ``config_defaults`` / - ``config`` / ``config_migrations`` hold OLD code and ``check_config_version()`` - reports "up to date" despite a newer pulled version with a migration to run. - Also reloads ``_subprocess_compat`` / ``dashboard_procs`` so the later dashboard - cleanup sees symbols the update added instead of dying with ImportError. - """ + """Force-reload config modules after git pull: the updater is the PRE-pull process, so the + cached modules hold OLD code and ``check_config_version()`` would report "up to date" despite a + pulled migration. ``_subprocess_compat`` / ``dashboard_procs`` reload too so the later dashboard + cleanup sees symbols the update added.""" import importlib importlib.invalidate_caches() for mod_name in ( @@ -40,7 +34,8 @@ def _reload_config_modules() -> None: def _run_config_check_fresh() -> tuple: - """Return ``(current_ver, latest_ver)`` using freshly-reloaded modules (see ``_reload_config_modules``).""" + """Return ``(current_ver, latest_ver)`` using freshly-reloaded modules (see + ``_reload_config_modules``).""" from hermes_cli.update_cmd import _reload_config_modules _reload_config_modules() from hermes_cli.config import check_config_version @@ -48,7 +43,8 @@ def _run_config_check_fresh() -> tuple: def _run_migrate_config_fresh(*, interactive: bool = False, quiet: bool = False) -> dict: - """Run config migration using freshly-reloaded modules (see ``_reload_config_modules``); returns results dict.""" + """Run config migration using freshly-reloaded modules (see ``_reload_config_modules``); returns + results dict.""" from hermes_cli.update_cmd import _reload_config_modules _reload_config_modules() from hermes_cli.config import migrate_config @@ -57,15 +53,11 @@ def _run_migrate_config_fresh(*, interactive: bool = False, quiet: bool = False) def _migrate_sibling_profile_configs() -> list[tuple[str, int, int]]: """Migrate every SIBLING profile's config.yaml (the shared checkout serves all profiles; - migrating only the active one left siblings on configs the new code couldn't read). - - Per sibling home (active one skipped — caller already did it): scope reads/writes via the - context-local HERMES_HOME override (thread-safe — never ``os.environ``) and run the - NON-INTERACTIVE quiet migration; prompt-requiring settings wait for that profile's own - interactive session. Returns ``[(profile_name, from_version, to_version), ...]`` for - profiles actually migrated. Never raises; a failing profile is skipped (its startup - migration remains the fallback). - """ + siblings were left on configs the new code couldn't read). Per sibling (active skipped): scope + via the context-local HERMES_HOME override (never ``os.environ``) and run the NON-INTERACTIVE + quiet migration — prompt-requiring settings wait for that profile's own session. Returns + ``[(name, from_version, to_version), ...]``; never raises (a failing profile falls back to its + startup migration).""" from hermes_cli.update_cmd import _run_config_check_fresh, _run_migrate_config_fresh migrated: list[tuple[str, int, int]] = [] with _best_effort('Sibling profile enumeration failed: %s'): @@ -165,12 +157,9 @@ def _check_and_apply_config_migration( assume_yes: bool = False, gateway_mode: bool = False, pre_update_snapshot_id: str | None = None) -> None: - """Check/apply config migrations on an update completion path. - - Must use freshly-reloaded modules (see ``_reload_config_modules``) and run on EVERY - completion path (post-pull, venv-repair retry, Node-deps repair on ``commit_count == 0``) - so an interrupted update that already pulled code doesn't strand an old config version. - """ + """Check/apply config migrations with freshly-reloaded modules. Runs on EVERY completion path + (post-pull, venv-repair, Node-deps repair on ``commit_count == 0``) so an interrupted update + that already pulled code doesn't strand an old config version.""" from hermes_cli.update_cmd import ( _gateway_prompt, _migrate_sibling_profile_configs, diff --git a/hermes_cli/update_cmd_deps.py b/hermes_cli/update_cmd_deps.py index 6f1b82e0b4..9510dd7366 100644 --- a/hermes_cli/update_cmd_deps.py +++ b/hermes_cli/update_cmd_deps.py @@ -1,8 +1,6 @@ -"""Post-``hermes update`` dependency sync: venv preflight, editable reinstall, lazy refresh, npm/Desktop rebuilds, self-lock deferral. - -Split out of ``update_cmd.py``; names are re-imported there so ``hermes_cli.update_cmd.`` -still resolves/monkeypatches. Origin helpers are imported lazily per function (no cycle; patches hold). -""" +"""Post-``hermes update`` dependency sync: venv preflight, editable reinstall, lazy refresh, +npm/Desktop rebuilds, self-lock deferral. Names are re-imported by ``update_cmd`` (so +``hermes_cli.update_cmd.`` resolves/monkeypatches); origin helpers are imported lazily.""" import logging from contextlib import suppress @@ -26,11 +24,10 @@ _INSTALL_DEFINING_FILES = "pyproject.toml", "setup.py", "setup.cfg", "MANIFEST.i def _editable_install_is_current(git_cmd, cwd, pre_pull_sha: str | None) -> bool: """True when the pulled commits cannot have invalidated the editable install. - ``uv pip install -e .`` always rewrites console-script shims; on Windows that rewrite is - why ``hermes.exe`` must be quarantined (lost race = ``os error 32``), so skip it when it - provably changes nothing. Safe because the editable finder uses a *static* module list: - only a new top-level module/package (needing a ``pyproject.toml`` diff, like deps and - scripts) can stale it. Fails closed: unresolvable pre-pull SHA or failed diff -> False. + ``uv pip install -e .`` always rewrites console-script shims (on Windows that is why + ``hermes.exe`` must be quarantined; lost race = ``os error 32``), so skip it when it provably + changes nothing. Safe because the editable finder uses a *static* module list: only a + ``pyproject.toml`` diff can stale it. Fails closed: no pre-pull SHA or failed diff -> False. """ if not pre_pull_sha: return False @@ -56,12 +53,11 @@ def _critical_module_import_failures( root, *, report_runtime_errors: bool = False) -> dict[str, tuple[str, str]]: """Import each ``_UPDATE_CRITICAL_MODULES`` entry in a subprocess; return failures in probe order. - Syntax validation only *parses*, so a partially-updated tree (reachable via the Windows - ZIP copy loop) parses yet dies with ``ImportError: cannot import name``. A subprocess - keeps the half-updated tree's import side effects out of the updater's ``sys.modules``; - uses the venv interpreter when present since ``hermes update`` may run under another Python. - Generic import-time exceptions are tolerated by default (may depend on local config); - ``report_runtime_errors=True`` exposes them so two checkout states can be compared. + Syntax validation only *parses*: a partially-updated tree (Windows ZIP copy loop) parses yet + dies with ``ImportError: cannot import name``. The subprocess (venv interpreter when present — + the updater may run under another Python) keeps its import side effects out of our + ``sys.modules``. Generic import-time exceptions are tolerated (may depend on local config) + unless ``report_runtime_errors=True``. """ from hermes_cli.update_cmd import _UPDATE_CRITICAL_MODULES, _m from hermes_constants import FIRST_PARTY_MODULE_ROOTS @@ -214,8 +210,8 @@ def _capture_active_tool_dependencies() -> list[str]: def _restore_active_tool_dependencies( dependencies: list[str], install_cmd_prefix: list[str], *, env: dict[str, str] | None = None ) -> None: - """Restore allowlisted ``hermes tools`` dependencies (from a pre-rebuild probe) into a rebuilt venv. - Never raises: a failed optional tool must not block the update, but must be reported.""" + """Restore allowlisted ``hermes tools`` dependencies (from a pre-rebuild probe) into a rebuilt + venv. Never raises: a failed optional tool must not block the update, but must be reported.""" from hermes_cli.update_cmd import _m if not dependencies: return @@ -284,13 +280,10 @@ def _refresh_active_lazy_features( *, env: dict[str, str] | None = None, features: list[str] | None = None) -> bool: - """Refresh previously-activated lazy backends (cold ones untouched) after a code update. - - The core install never touches ``lazy_deps`` backends, so a bumped :data:`LAZY_DEPS` pin - would otherwise leave them stale forever. Returns True when the venv is safe to use - (refreshed, nothing active, or import repair succeeded); False when a failed lazy install - left broken core imports repair couldn't fix. Never raises. - """ + """Refresh previously-activated lazy backends (cold ones untouched): the core install never + touches them, so a bumped :data:`LAZY_DEPS` pin would leave them stale forever. Returns True + when the venv is safe (refreshed / nothing active / import repair succeeded), False when a + failed lazy install left broken core imports repair couldn't fix. Never raises.""" from hermes_cli.update_cmd import _m try: from tools import lazy_deps @@ -364,12 +357,9 @@ def _refresh_active_lazy_features( def _refresh_active_memory_provider_dependencies() -> None: - """Refresh pip deps for the configured external memory provider. - - Bridge packages live in each provider's ``plugin.yaml``, not Hermes extras or ``LAZY_DEPS``, - so the core reinstall can strip/downgrade them; re-run the ACTIVE provider's install after - core install and lazy refresh so its writes land last. Never raises. - """ + """Refresh pip deps for the configured external memory provider: its bridge packages live in + ``plugin.yaml`` (not Hermes extras / ``LAZY_DEPS``), so the core reinstall can strip them; + re-run the ACTIVE provider's install last so its writes land last. Never raises.""" try: from hermes_cli.config import load_config cfg = load_config() @@ -455,14 +445,10 @@ def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None: def _npm_manifest_paths() -> tuple[Path, ...]: - """Manifests whose changes must defeat the update-skip. - - The lockfile alone isn't enough: a package.json can be edited without running npm, and - `hermes update` is the step expected to sync node_modules. Workspaces come from the root - `workspaces` globs so a new one can't escape the key; every workspace manifest counts - (desktop too) because the single lockfile spans the whole graph. Falls back to root - manifests only if package.json is unreadable. - """ + """Manifests whose changes must defeat the update-skip. The lockfile alone isn't enough (a + package.json can be edited without running npm); workspaces come from the root ``workspaces`` + globs so a new one can't escape the key, and every workspace counts (desktop too) because the + single lockfile spans the whole graph. Root manifests only if package.json is unreadable.""" from hermes_cli.update_cmd import _m root_pkg = _m().PROJECT_ROOT / "package.json" paths = [_m().PROJECT_ROOT / "package-lock.json", root_pkg] @@ -479,7 +465,8 @@ def _npm_manifest_paths() -> tuple[Path, ...]: def _npm_manifests_digest() -> str | None: - """Combined sha256 over lockfile + all workspace package.json; None when lockfile missing (never skip).""" + """Combined sha256 over lockfile + all workspace package.json; None when lockfile missing (never + skip).""" from hermes_cli.update_cmd import _m if not (_m().PROJECT_ROOT / "package-lock.json").exists(): return None @@ -540,13 +527,9 @@ def _repair_node_deps_on_current_checkout( pre_update_snapshot_id: str | None = None, completion_message: str = "✓ Already up to date!", had_desktop_app_before_update: bool = False) -> bool: - """Repair Node deps on the ``commit_count == 0`` path. - - A current checkout doesn't imply healthy Node deps (a failed npm install says "re-run - hermes update" but the early return skipped the refresh). ``_update_node_dependencies`` - self-gates on the hash recorded only after a SUCCESSFUL install, so this is a cheap - no-op when healthy and a real repair otherwise. - """ + """Repair Node deps on the ``commit_count == 0`` path: a failed npm install says "re-run hermes + update" but the early return used to skip the refresh. ``_update_node_dependencies`` self-gates + on the hash recorded only after a SUCCESSFUL install, so this is a cheap no-op when healthy.""" from hermes_cli.update_cmd import ( _check_and_apply_config_migration, _m, @@ -653,12 +636,9 @@ def _update_node_dependencies() -> list[str]: def _venv_core_imports_healthy() -> tuple[bool, str]: - """Probe the venv (in ITS interpreter — updater may run under another Python) for core imports. - - Catches a half-updated venv where the checkout is current but a dependency sync died - partway; otherwise "Already up to date!" never re-syncs. Returns ``(healthy, detail)``. - Never raises; unknown states report healthy so a probe failure can't force reinstalls. - """ + """Probe the venv (in ITS interpreter — the updater may run under another Python) for core + imports, catching a half-updated venv that "Already up to date!" would otherwise never re-sync. + Returns ``(healthy, detail)``; never raises, unknown states report healthy.""" from hermes_cli.update_cmd import _m venv_dir = _m().PROJECT_ROOT / "venv" venv_python = venv_python_path(venv_dir, windows=_m()._is_windows()) @@ -879,15 +859,12 @@ def _path_uid(path) -> Optional[int]: def _venv_foreign_owned_paths(venv_root, limit: int = 5) -> list: - """Bounded scan for venv entries not owned by the current user; returns up to ``limit`` - ``(path_str, uid)`` tuples. + """Up to ``limit`` ``(path_str, uid)`` venv entries not owned by the current user. - A venv touched by ``sudo pip``/``sudo hermes`` has root-owned files, and a later normal - update dies mid-mutation with ``venv/bin/hermes`` already deleted — never mutate a venv we - can't safely mutate. Deliberately BOUNDED (venv root, ``venv/bin``, top-level of first - site-packages, children of each ``*.dist-info``; ~2000 stats max). POSIX-only: ``[]`` on - Windows and as root. Swallows per-entry ``OSError``; ``[]`` on any structural surprise — - must NEVER raise or add noticeable latency. + A venv touched by ``sudo pip``/``sudo hermes`` dies mid-update with ``venv/bin/hermes`` already + deleted — never mutate a venv we can't safely mutate. Deliberately BOUNDED (venv root, + ``venv/bin``, first site-packages top level, ``*.dist-info`` children; ~2000 stats). POSIX-only: + ``[]`` on Windows and as root; ``[]`` on any surprise — must NEVER raise or add latency. """ from hermes_cli.update_cmd import _path_uid try: