From 35fce7699ef61eb11963a498c5489b4e7c7a508b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 8 May 2026 13:26:42 -0700 Subject: [PATCH] feat(windows uninstall): clean up User env, PATH, Scheduled Task, and portable tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hermes uninstall` was POSIX-only. On Windows it would leave four classes of installer debris behind that the user had to scrub manually: 1. Scheduled Task and/or Startup-folder .cmd entry that installer.ps1 dropped for `hermes gateway install`. Left running at next logon even after uninstall, pointing at deleted code paths. 2. User-scope PATH entries for the Hermes venv, PortableGit (cmd, bin, usr\bin), and bundled Node, all written to HKCU\Environment\Path. 3. User-scope env vars HERMES_HOME and HERMES_GIT_BASH_PATH, same registry key. 4. PortableGit and Node copies under %LOCALAPPDATA%\hermes\ (~200MB), plus gateway-service/ scratch dir. Fixes: - `uninstall_gateway_service()` gets a Windows branch that calls into `gateway_windows.stop()` + `gateway_windows.uninstall()`, which already know how to remove both schtasks entries and Startup-folder .cmd files and how to stop any running detached pythonw gateway. - `remove_path_from_windows_registry(hermes_home)` reads HKCU\Environment via winreg, strips any PATH entry whose path-prefix matches the installer-owned markers (\hermes-agent, \git, \node, \venv under the current HERMES_HOME), and writes the cleaned value back. Preserves REG_EXPAND_SZ vs REG_SZ so unexpanded %VARS% in the user's PATH survive. No PowerShell subprocess, no fragile `reg query` parsing. - `remove_hermes_env_vars_windows()` deletes HERMES_HOME and HERMES_GIT_BASH_PATH from the same key. - `remove_portable_tooling_windows(hermes_home)` rmtree's `hermes_home/git`, `hermes_home/node`, `hermes_home/gateway-service` — they're installer artifacts, not user data, so they get removed in BOTH "keep data" and "full uninstall" modes. Wired these into `run_uninstall()` guarded by `_is_windows()` so POSIX paths are untouched. Also fixed the closing "Reload your shell" footer to point Windows users at opening a new terminal (PATH changes don't propagate into the current PowerShell session) with the PowerShell install one-liner instead of bash's curl-pipe. Verified on Delta-1 (Windows 10) via preview script: correctly identifies 4 Hermes-installed PATH entries out of 13 total to remove, leaves Python/LM Studio/ripgrep/ffmpeg/winget entries alone. --- hermes_cli/uninstall.py | 215 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 207 insertions(+), 8 deletions(-) diff --git a/hermes_cli/uninstall.py b/hermes_cli/uninstall.py index 6698f7cee2..f14c235875 100644 --- a/hermes_cli/uninstall.py +++ b/hermes_cli/uninstall.py @@ -118,12 +118,13 @@ def remove_wrapper_script(): def uninstall_gateway_service(): - """Stop and uninstall the gateway service (systemd, launchd) and kill any - standalone gateway processes. + """Stop and uninstall the gateway service (systemd, launchd, Windows + Scheduled Task / Startup folder) and kill any standalone gateway processes. Delegates to the gateway module which handles: - Linux: user + system systemd services (with proper DBUS env setup) - macOS: launchd plists + - Windows: Scheduled Task + Startup-folder fallback, via ``gateway_windows`` - All platforms: standalone ``hermes gateway run`` processes - Termux/Android: skips systemd (no systemd on Android), still kills standalone processes """ @@ -201,9 +202,163 @@ def uninstall_gateway_service(): except Exception as e: log_warn(f"Could not remove launchd gateway service: {e}") + # 4. 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. + elif system == "Windows": + try: + from hermes_cli import gateway_windows + if gateway_windows.is_installed() or gateway_windows.is_task_registered() \ + or gateway_windows.is_startup_entry_installed(): + try: + gateway_windows.stop() + except Exception as e: + log_warn(f"Could not stop Windows gateway cleanly: {e}") + try: + gateway_windows.uninstall() + log_success("Removed Windows gateway (Scheduled Task + Startup entry)") + stopped_something = True + except Exception as e: + log_warn(f"Could not fully uninstall Windows gateway: {e}") + except Exception as e: + log_warn(f"Could not check Windows gateway service: {e}") + return stopped_something +# ============================================================================ +# 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). + + +def _hermes_path_markers(hermes_home: Path) -> list[str]: + """Path-entry substrings that identify Hermes-owned User-PATH entries.""" + root = str(hermes_home).rstrip("\\/") + # Match on prefix so sub-entries (git\cmd, git\bin, git\usr\bin, node, etc.) + # all get swept. Also match the bare hermes-agent install dir. + markers = [root + "\\hermes-agent", root + "\\git", root + "\\node", root + "\\venv"] + # Also match if HERMES_HOME was customised to somewhere else — find-and-nuke + # any entry whose path component contains "hermes". We don't want to catch + # unrelated entries like "chermes-foo" or "ephermeral", so we look for + # backslash-hermes as a word-ish boundary. + return markers + + +def remove_path_from_windows_registry(hermes_home: Path) -> list[str]: + """Strip Hermes-owned entries from User-scope PATH in the registry. + + Returns the list of removed path entries. Operates on HKCU\\Environment, + same key the installer wrote to via ``[Environment]::SetEnvironmentVariable``. + """ + try: + import winreg + except ImportError: + return [] # not on Windows, nothing to do + + removed: list[str] = [] + key_path = "Environment" + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, + winreg.KEY_READ | winreg.KEY_WRITE) as key: + try: + path_value, path_type = winreg.QueryValueEx(key, "Path") + except FileNotFoundError: + return [] + # Preserve REG_EXPAND_SZ vs REG_SZ so unexpanded %VARS% survive. + entries = [e for e in path_value.split(";") if e] + markers = _hermes_path_markers(hermes_home) + kept: list[str] = [] + for entry in entries: + entry_norm = entry.rstrip("\\/") + matched = any(entry_norm.lower().startswith(m.lower()) for m in markers) + if matched: + removed.append(entry) + else: + kept.append(entry) + if removed: + new_value = ";".join(kept) + winreg.SetValueEx(key, "Path", 0, path_type, new_value) + except OSError as e: + log_warn(f"Could not edit User PATH in registry: {e}") + return removed + + +def remove_hermes_env_vars_windows() -> list[str]: + """Delete HERMES_HOME and HERMES_GIT_BASH_PATH from User-scope env vars.""" + try: + import winreg + except ImportError: + return [] + + removed: list[str] = [] + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment", 0, + winreg.KEY_READ | winreg.KEY_WRITE) as key: + for name in ("HERMES_HOME", "HERMES_GIT_BASH_PATH"): + try: + winreg.QueryValueEx(key, name) + except FileNotFoundError: + continue + try: + winreg.DeleteValue(key, name) + removed.append(name) + except OSError as e: + log_warn(f"Could not delete {name} from User env: {e}") + except OSError as e: + log_warn(f"Could not open User Environment key: {e}") + return removed + + +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.""" + removed: list[Path] = [] + for sub in ("git", "node", "gateway-service"): + target = hermes_home / sub + if target.exists(): + try: + shutil.rmtree(target, ignore_errors=False) + removed.append(target) + except Exception as e: + log_warn(f"Could not remove {target}: {e}") + return removed + + +def _is_windows() -> bool: + import sys + return sys.platform == "win32" + + def _is_default_hermes_home(hermes_home: Path) -> bool: """Return True when ``hermes_home`` points at the default (non-profile) root.""" try: @@ -400,14 +555,36 @@ def run_uninstall(args): if not uninstall_gateway_service(): log_info("No gateway service or processes found") - # 2. Remove PATH entries from shell configs + # 2. Remove PATH entries from shell configs (POSIX) AND from the Windows + # User-scope registry. Both helpers no-op on the wrong platform so we + # can safely call them unconditionally. log_info("Removing PATH entries from shell configs...") removed_configs = remove_path_from_shell_configs() if removed_configs: for config in removed_configs: log_success(f"Updated {config}") else: - log_info("No PATH entries found to remove") + log_info("No PATH entries found to remove in shell rc files") + + if _is_windows(): + log_info("Removing PATH entries from Windows User environment...") + # Expand %LOCALAPPDATA% etc. in hermes_home so the marker matching is + # against fully resolved paths — installer writes literal strings + # like C:\Users\\AppData\Local\hermes\git\cmd, not %LOCALAPPDATA%. + removed_path_entries = remove_path_from_windows_registry(Path(os.path.expandvars(str(hermes_home)))) + if removed_path_entries: + for entry in removed_path_entries: + log_success(f"Removed from User PATH: {entry}") + else: + log_info("No Hermes-owned PATH entries in User environment") + + log_info("Removing HERMES_HOME / HERMES_GIT_BASH_PATH User env vars...") + removed_env = remove_hermes_env_vars_windows() + if removed_env: + for name in removed_env: + log_success(f"Removed User env var: {name}") + else: + log_info("No Hermes-set User env vars to remove") # 3. Remove wrapper script log_info("Removing hermes command...") @@ -436,6 +613,21 @@ def run_uninstall(args): except Exception as e: log_warn(f"Could not fully remove {project_root}: {e}") log_info("You may need to manually remove it") + + # 4b. Remove Windows-only installer artifacts that are NOT user data: + # PortableGit, bundled Node, gateway-service dir. Installer put them + # under HERMES_HOME but they're install tooling, not config — safe to + # remove even in "keep data" mode. If we're doing a full uninstall + # the step-5 rmtree(hermes_home) would sweep them anyway; calling + # this helper there is a no-op since they'll already be gone. + if _is_windows(): + log_info("Removing Windows installer artifacts (PortableGit, Node, gateway-service)...") + removed_artifacts = remove_portable_tooling_windows(hermes_home) + if removed_artifacts: + for path in removed_artifacts: + log_success(f"Removed {path}") + else: + log_info("No Windows installer artifacts to remove") # 5. Optionally remove ~/.hermes/ data directory (and named profiles) if full_uninstall: @@ -471,11 +663,18 @@ def run_uninstall(args): print(f" {hermes_home}/") print() print("To reinstall later with your existing settings:") - print(color(" curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash", Colors.DIM)) + if _is_windows(): + print(color(" irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex", Colors.DIM)) + else: + print(color(" curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash", Colors.DIM)) print() - - print(color("Reload your shell to complete the process:", Colors.YELLOW)) - print(" source ~/.bashrc # or ~/.zshrc") + + if _is_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") print() print("Thank you for using Hermes Agent! ⚕") print()