feat: uninstall the Chat GUI without removing the agent (CLI + desktop UI) (#40355)

* feat: uninstall the Chat GUI without removing the agent (CLI + desktop UI)

Adds a GUI-only uninstall path so people can remove the desktop Chat GUI
while keeping the Hermes agent + their config/sessions/.env, and surfaces
the three CLI uninstall modes inside the desktop app's Settings → About.

CLI:
- New hermes_cli/gui_uninstall.py: cross-platform discovery + removal of the
  desktop GUI's artifacts (source-built dist/release/node_modules + build
  stamp, the packaged app bundle, and the Electron userData dir) on Linux,
  macOS, and Windows. Never touches the agent source, venv, or user data.
- `hermes uninstall --gui` removes only the Chat GUI; `--gui-summary` prints a
  JSON install snapshot (used by the desktop UI to gate options + detect a
  missing agent for a future lite client).
- `hermes uninstall --yes` / `--full --yes` now run non-interactively, sharing
  the destructive sequence via a new _perform_uninstall() helper. The keep-data
  and full flows also sweep the GUI artifacts.

Desktop:
- electron/desktop-uninstall.cjs: pure helpers mapping each mode (gui/lite/full)
  to CLI flags, resolving the running app bundle per OS, and building the
  detached cleanup script that waits for the app to exit, runs the Python
  uninstall, and removes the bundle.
- IPC hermes:uninstall:summary / :run, preload bridge, and types.
- Settings → About "Danger zone" with the three options; agent-removing
  options hide when no local agent is detected.

Tests: tests/hermes_cli/test_gui_uninstall.py (22 pass with the existing
uninstall tests), electron/desktop-uninstall.test.cjs (17 pass, wired into
test:desktop:platforms). Docs: desktop.md "Uninstalling" + cli-commands.md.

* fix(desktop): tear down backend process tree before GUI uninstall (Windows lock safety)

The desktop uninstall cleanup script waited only on the desktop app's own
PID, but a backend grandchild (gateway / pty terminal / hermes REPL) can
outlive it and keep hermes.exe + venv files mandatory-locked on Windows —
making the script's rmdir half-fail and leaving a partial install, the same
failure class as the self-update path's #37532.

- main.cjs: runDesktopUninstall now awaits releaseBackendLock() before
  spawning the cleanup script — tree-kills every backend PID the desktop owns
  (primary + pool) via taskkill /T /F and polls the venv shim until unlocked.
  Extracted the shared core out of releaseBackendLockForUpdate so both the
  update hand-off and the uninstaller use the identical, incident-hardened
  teardown. No-op on macOS/Linux (no mandatory locks).
- desktop-uninstall.cjs: Windows cleanup script removes the bundle via a
  bounded rmdir retry loop (10x, 1s) instead of a single rmdir, since Windows
  releases directory handles lazily even after the holding process exits.
- Dropped a fragile tasklist|findstr reap-by-path attempt; the Electron-side
  tree-kill-by-PID is the reliable mechanism.

Tests: desktop-uninstall.test.cjs updated for the retry-loop output (17 pass).

* fix(desktop): address review on GUI uninstall (venv self-delete, gates, wait-loop)

Resolves @OutThisLife's review on #40355:

1. full mode now gated on agent presence (needsAgent: true). It removes the
   agent + user data, so on a lite client with no local agent it's hidden
   like lite — no more offering to remove an agent that isn't there.

2. (Finding 3, the real bug) lite/full no longer rmtree the venv from the
   venv's OWN python. On Windows a running python.exe is mandatory-locked, so
   that half-fails. New lightweight 'python -m hermes_cli.uninstall --mode X'
   entrypoint (stdlib-only imports) lets the desktop run agent-removing modes
   under the SYSTEM python (findSystemPython) with PYTHONPATH=<agentRoot>, so
   import hermes_cli resolves from source while the venv is torn down. Falls
   back to venv python + logs when no system python (gui-only unaffected).

3. Windows wait-loop is now bounded (60 tries, matching POSIX) and matches the
   PID as a whole space-delimited token via findstr (no substring 99->990
   trap, no redundant bare find). set HERMES_HOME/PID/PYTHONPATH now quoted.

4. Renamed the misleading 'returns null for dev run' test — the dev-run safety
   is shouldRemoveAppBundle(isPackaged=false), which the test now asserts.

Docs: note that --gui on a source checkout also sweeps node_modules/build
output. Tests: 18 python + 19 desktop pass.
This commit is contained in:
Teknium
2026-06-06 18:22:38 -07:00
committed by GitHub
parent f2e8234307
commit 5b43bf7d02
14 changed files with 1784 additions and 10 deletions
+285
View File
@@ -0,0 +1,285 @@
"""
Hermes Desktop (Chat GUI) uninstaller.
The desktop GUI ships in two shapes and this module knows how to find and
remove the artifacts of both, on Linux, macOS, and Windows, WITHOUT touching
the Python agent or the user's config/data:
1. Source-built GUI (``hermes desktop`` / ``hermes gui``)
Built inside the agent checkout under ``$HERMES_HOME/hermes-agent/``:
- ``apps/desktop/dist`` (compiled renderer)
- ``apps/desktop/release`` (electron-builder unpacked app + installers)
- ``apps/desktop/node_modules`` and the workspace-root ``node_modules``
(Electron itself, ~200MB) — only removed on a GUI uninstall because
the agent does not need them.
- ``$HERMES_HOME/desktop-build-stamp.json`` (the build freshness stamp)
2. Packaged distributable (DMG / NSIS / AppImage / deb / rpm)
Installed by the OS to a standard application location and carrying its
own bundled Electron + a per-user Electron ``userData`` directory:
- macOS: ``/Applications/Hermes.app`` or ``~/Applications/Hermes.app``
- Windows: ``%LOCALAPPDATA%\\Programs\\Hermes`` (NSIS per-user)
- Linux: ``~/.local/share/applications`` .desktop entry + AppImage
In both shapes the Electron runtime keeps a ``userData`` directory keyed on
the app name ("Hermes"), separate from ``$HERMES_HOME``:
- macOS: ``~/Library/Application Support/Hermes``
- Windows: ``%APPDATA%\\Hermes``
- Linux: ``$XDG_CONFIG_HOME/Hermes`` (default ``~/.config/Hermes``)
This holds the desktop's own ``connection.json`` / ``updates.json`` and
Chromium cache — pure GUI state, safe to remove on a GUI uninstall.
The functions here are deliberately import-light and side-effect-free at
import time so the Electron main process can shell out to
``hermes uninstall --gui`` (and friends) without paying for the full CLI.
"""
import os
import shutil
import sys
from pathlib import Path
from hermes_constants import get_hermes_home
from hermes_cli.colors import Colors, color
def log_info(msg: str):
print(f"{color('', Colors.CYAN)} {msg}")
def log_success(msg: str):
print(f"{color('', Colors.GREEN)} {msg}")
def log_warn(msg: str):
print(f"{color('', Colors.YELLOW)} {msg}")
# ---------------------------------------------------------------------------
# Discovery
# ---------------------------------------------------------------------------
def _agent_root(hermes_home: Path) -> Path:
"""The agent checkout root — same layout install.sh / install.ps1 use."""
return hermes_home / "hermes-agent"
def desktop_userdata_dir() -> Path:
"""Return the Electron ``userData`` directory for the desktop app.
Mirrors Electron's ``app.getPath('userData')`` for an app named "Hermes"
on each platform. This is GUI-only state (connection.json, updates.json,
Chromium cache) and never holds agent config or sessions.
"""
home = Path.home()
if sys.platform == "darwin":
return home / "Library" / "Application Support" / "Hermes"
if sys.platform == "win32":
appdata = os.environ.get("APPDATA")
base = Path(appdata) if appdata else (home / "AppData" / "Roaming")
return base / "Hermes"
# Linux / other POSIX — XDG config home.
xdg = os.environ.get("XDG_CONFIG_HOME")
base = Path(xdg) if xdg else (home / ".config")
return base / "Hermes"
def source_built_gui_artifacts(hermes_home: Path) -> "list[Path]":
"""GUI build artifacts produced by ``hermes desktop`` inside the checkout.
These are removable on a GUI uninstall without harming the agent: the
Python agent runs from ``hermes-agent/`` source + ``venv/`` and never
needs the Electron build output or node_modules.
"""
agent_root = _agent_root(hermes_home)
desktop_dir = agent_root / "apps" / "desktop"
return [
desktop_dir / "dist",
desktop_dir / "release",
desktop_dir / "node_modules",
# Workspace-root node_modules carries Electron (devDependency of the
# desktop workspace, ~200MB). The agent does not use any npm package,
# so this is GUI tooling — safe to drop on a GUI uninstall.
agent_root / "node_modules",
hermes_home / "desktop-build-stamp.json",
]
def packaged_gui_app_paths() -> "list[Path]":
"""Standard install locations of the packaged desktop distributable.
Returns every candidate for the current OS; the caller filters to those
that actually exist. We never glob system-wide — only the well-known
electron-builder output locations for the "Hermes" product.
"""
home = Path.home()
paths: list[Path] = []
if sys.platform == "darwin":
paths += [
Path("/Applications/Hermes.app"),
home / "Applications" / "Hermes.app",
]
elif sys.platform == "win32":
local = os.environ.get("LOCALAPPDATA")
local_base = Path(local) if local else (home / "AppData" / "Local")
paths += [
# NSIS per-user install (perMachine=false → Programs\Hermes).
local_base / "Programs" / "Hermes",
# Older / alternate layout some builds used.
local_base / "hermes-desktop",
]
program_files = os.environ.get("ProgramFiles")
if program_files:
# NSIS per-machine fallback (needs admin to remove).
paths.append(Path(program_files) / "Hermes")
else:
# Linux: AppImage is a single file the user placed somewhere; we can
# only reliably clean the desktop entry + icon we know the name of.
# The AppImage itself lives wherever the user put it, so we surface a
# hint rather than guessing. deb/rpm installs are owned by the system
# package manager and must be removed via apt/dnf — see the message in
# ``uninstall_gui``.
data = os.environ.get("XDG_DATA_HOME")
data_base = Path(data) if data else (home / ".local" / "share")
paths += [
data_base / "applications" / "hermes.desktop",
data_base / "applications" / "Hermes.desktop",
]
return paths
def agent_is_installed(hermes_home: Path) -> bool:
"""Return True when a usable Python agent install exists under HERMES_HOME.
Used by the desktop UI to decide which uninstall options to offer: if the
agent isn't present (a future "lite" GUI-only client), the "remove agent"
options are hidden.
"""
agent_root = _agent_root(hermes_home)
# A real install has the package source + a venv. Either signal alone is
# enough — a source checkout without a venv is still "the agent is here".
if (agent_root / "hermes_cli").is_dir():
return True
if (agent_root / "venv").is_dir() or (agent_root / ".venv").is_dir():
return True
return False
def gui_is_installed(hermes_home: Path) -> bool:
"""Return True when any desktop GUI artifact exists (built or packaged)."""
for p in source_built_gui_artifacts(hermes_home):
if p.exists():
return True
for p in packaged_gui_app_paths():
if p.exists():
return True
if desktop_userdata_dir().exists():
return True
return False
def gui_install_summary(hermes_home: "Path | None" = None) -> dict:
"""Structured snapshot of what's installed, for the desktop UI to render.
Returns JSON-serializable primitives so the Electron main process can
forward it to the renderer via IPC (paths as strings, booleans for the
high-level questions the UI gates options on).
"""
home: Path = hermes_home if hermes_home is not None else get_hermes_home()
source_artifacts = [p for p in source_built_gui_artifacts(home) if p.exists()]
packaged = [p for p in packaged_gui_app_paths() if p.exists()]
userdata = desktop_userdata_dir()
return {
"hermes_home": str(home),
"agent_installed": agent_is_installed(home),
"gui_installed": gui_is_installed(home),
"source_built_artifacts": [str(p) for p in source_artifacts],
"packaged_app_paths": [str(p) for p in packaged],
"userdata_dir": str(userdata),
"userdata_exists": userdata.exists(),
"platform": sys.platform,
}
# ---------------------------------------------------------------------------
# Removal
# ---------------------------------------------------------------------------
def _remove_path(path: Path) -> bool:
"""Remove a file or directory tree. Returns True when something was removed."""
try:
if path.is_symlink() or path.is_file():
path.unlink()
return True
if path.is_dir():
shutil.rmtree(path)
return True
except Exception as e:
log_warn(f"Could not remove {path}: {e}")
return False
def uninstall_gui(hermes_home: "Path | None" = None, *, remove_userdata: bool = True) -> "list[Path]":
"""Remove the desktop GUI's artifacts, leaving the agent + user data intact.
Removes:
- source-built GUI artifacts (dist/release/node_modules/build-stamp)
- the packaged app bundle / install dir (best-effort; deb/rpm need the
system package manager and are reported, not force-removed)
- the Electron ``userData`` directory (unless ``remove_userdata=False``)
Never touches ``hermes-agent/hermes_cli`` (agent source), ``venv/``, or any
config / sessions / .env under ``$HERMES_HOME``.
Returns the list of paths actually removed.
"""
home: Path = hermes_home if hermes_home is not None else get_hermes_home()
removed: list[Path] = []
log_info("Removing built GUI artifacts (renderer, release, node_modules)...")
for path in source_built_gui_artifacts(home):
if path.exists() and _remove_path(path):
log_success(f"Removed {path}")
removed.append(path)
log_info("Removing installed desktop app...")
found_packaged = False
for path in packaged_gui_app_paths():
if path.exists():
found_packaged = True
if _remove_path(path):
log_success(f"Removed {path}")
removed.append(path)
if not found_packaged:
log_info("No packaged desktop app found in standard locations")
if remove_userdata:
userdata = desktop_userdata_dir()
if userdata.exists():
log_info("Removing desktop app data (Electron userData)...")
if _remove_path(userdata):
log_success(f"Removed {userdata}")
removed.append(userdata)
if not removed:
log_info("No desktop GUI artifacts found to remove")
# Linux deb/rpm installs are owned by the package manager; we can't (and
# shouldn't) rmtree files under /usr. Surface the hint so the user can
# finish the job. AppImages live wherever the user dropped them.
if sys.platform.startswith("linux"):
log_info(
"If you installed the desktop via a .deb / .rpm package, remove it "
"with your package manager (e.g. 'sudo apt remove hermes' or "
"'sudo dnf remove hermes'). AppImage builds are a single file you "
"can delete from wherever you saved it."
)
return removed
+35 -2
View File
@@ -6697,8 +6697,30 @@ def cmd_version(args):
def cmd_uninstall(args):
"""Uninstall Hermes Agent."""
_require_tty("uninstall")
"""Uninstall Hermes Agent (or just the Chat GUI with --gui)."""
# Machine-readable install snapshot for the desktop app's uninstall UI.
# Must run before any TTY gate — it's called from a non-interactive child.
if getattr(args, "gui_summary", False):
from hermes_cli.gui_uninstall import gui_install_summary
print(json.dumps(gui_install_summary()))
return
# GUI-only uninstall. The desktop app shells out to this non-interactively
# with --yes, so only gate on a TTY when we actually need to prompt.
if getattr(args, "gui", False):
if not getattr(args, "yes", False):
_require_tty("uninstall --gui")
from hermes_cli.uninstall import run_gui_uninstall
run_gui_uninstall(args)
return
# Full/keep-data uninstall. ``--yes`` runs non-interactively (the desktop
# app's lite/full modes drive this from a detached cleanup script), so only
# gate on a TTY when we actually need to prompt for the option + confirm.
if not getattr(args, "yes", False):
_require_tty("uninstall")
from hermes_cli.uninstall import run_uninstall
run_uninstall(args)
@@ -15454,6 +15476,17 @@ Examples:
action="store_true",
help="Full uninstall - remove everything including configs and data",
)
uninstall_parser.add_argument(
"--gui",
action="store_true",
help="Uninstall only the desktop Chat GUI, leaving the agent intact",
)
uninstall_parser.add_argument(
"--gui-summary",
action="store_true",
help="Print a JSON summary of installed GUI/agent artifacts and exit "
"(used by the desktop app to gate uninstall options)",
)
uninstall_parser.add_argument(
"--yes", "-y", action="store_true", help="Skip confirmation prompts"
)
+182 -2
View File
@@ -492,6 +492,78 @@ def _uninstall_profile(profile) -> None:
log_warn(f" Could not remove {profile_home}: {e}")
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.
"""
from hermes_cli.gui_uninstall import (
agent_is_installed,
gui_install_summary,
uninstall_gui,
)
hermes_home = get_hermes_home()
summary = gui_install_summary(hermes_home)
skip_confirm = bool(getattr(args, "yes", False))
print()
print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA, Colors.BOLD))
print(color("│ ⚕ Hermes Chat GUI Uninstaller │", Colors.MAGENTA, Colors.BOLD))
print(color("└─────────────────────────────────────────────────────────┘", Colors.MAGENTA, Colors.BOLD))
print()
if not summary["gui_installed"]:
print("No Hermes Chat GUI installation was found.")
print(f" Checked: {hermes_home}, and the standard app locations for this OS.")
return
print(color("This removes the Chat GUI only. The Hermes agent stays installed.", Colors.CYAN))
print()
print(color("Will remove:", Colors.YELLOW, Colors.BOLD))
for p in summary["source_built_artifacts"]:
print(f"{p}")
for p in summary["packaged_app_paths"]:
print(f"{p}")
if summary["userdata_exists"]:
print(f"{summary['userdata_dir']} (desktop app data)")
print()
if agent_is_installed(hermes_home):
print(color("Kept intact:", Colors.GREEN, Colors.BOLD))
print(f" • The Hermes agent at {hermes_home / 'hermes-agent'}")
print(f" • Your config, sessions, and secrets under {hermes_home}")
print()
if not skip_confirm:
try:
confirm = input(f"Type '{color('yes', Colors.YELLOW)}' to remove the Chat GUI: ").strip().lower()
except (KeyboardInterrupt, EOFError):
print()
print("Cancelled.")
return
if confirm != "yes":
print()
print("Uninstall cancelled.")
return
print()
print(color("Uninstalling Chat GUI...", Colors.CYAN, Colors.BOLD))
print()
uninstall_gui(hermes_home)
print()
print(color("┌─────────────────────────────────────────────────────────┐", Colors.GREEN, Colors.BOLD))
print(color("│ ✓ Chat GUI Uninstalled! │", Colors.GREEN, Colors.BOLD))
print(color("└─────────────────────────────────────────────────────────┘", Colors.GREEN, Colors.BOLD))
print()
print("The Hermes agent is still installed. Run 'hermes' to use the CLI,")
print("or 'hermes uninstall' to remove the agent too.")
print()
def run_uninstall(args):
"""
Run the uninstall process.
@@ -509,6 +581,24 @@ def run_uninstall(args):
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.
skip_confirm = bool(getattr(args, "yes", False))
if skip_confirm:
full_uninstall = bool(getattr(args, "full", False))
_perform_uninstall(
project_root=project_root,
hermes_home=hermes_home,
full_uninstall=full_uninstall,
remove_profiles=False,
named_profiles=named_profiles,
)
return
print()
print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA, Colors.BOLD))
print(color("│ ⚕ Hermes Agent Uninstaller │", Colors.MAGENTA, Colors.BOLD))
@@ -604,7 +694,32 @@ def run_uninstall(args):
print()
print("Uninstall cancelled.")
return
_perform_uninstall(
project_root=project_root,
hermes_home=hermes_home,
full_uninstall=full_uninstall,
remove_profiles=remove_profiles,
named_profiles=named_profiles,
)
def _perform_uninstall(
*,
project_root: Path,
hermes_home: Path,
full_uninstall: bool,
remove_profiles: bool,
named_profiles: list,
) -> None:
"""Execute the uninstall steps. Shared by the interactive and ``--yes``
paths so the destructive sequence lives in exactly one place.
Steps: stop gateway → strip PATH (rc files + Windows registry) → remove the
``hermes`` wrapper + node symlinks → remove the desktop Chat GUI artifacts →
delete the code checkout → (Windows) remove PortableGit/Node → optionally
wipe ``$HERMES_HOME`` data and named profiles on full uninstall.
"""
print()
print(color("Uninstalling...", Colors.CYAN, Colors.BOLD))
print()
@@ -664,7 +779,25 @@ def run_uninstall(args):
log_success(f"Removed {link}")
else:
log_info("No Hermes-managed node/npm/npx symlinks found")
# 3c. Remove the desktop Chat GUI's artifacts too (built renderer/release,
# node_modules, the packaged app bundle, and the Electron userData
# dir). Both the "keep data" and "full" CLI flows remove the agent
# code, so the GUI — which is just another consumer of the same
# checkout — should go with it. uninstall_gui() never touches config /
# sessions / .env, so it's safe in keep-data mode; on full uninstall the
# step-5 rmtree(hermes_home) would sweep the in-tree artifacts anyway,
# but the packaged app + Electron userData live OUTSIDE HERMES_HOME and
# must be cleaned explicitly here.
log_info("Removing desktop Chat GUI artifacts...")
try:
from hermes_cli.gui_uninstall import uninstall_gui
gui_removed = uninstall_gui(hermes_home)
if not gui_removed:
log_info("No desktop GUI artifacts found")
except Exception as e:
log_warn(f"Could not remove desktop GUI artifacts: {e}")
# 4. Remove installation directory (code)
log_info("Removing installation directory...")
@@ -748,3 +881,50 @@ def run_uninstall(args):
print()
print("Thank you for using Hermes Agent! ⚕")
print()
class _UninstallArgs:
"""Lightweight args namespace for the module entrypoint below."""
def __init__(self, *, mode: str):
self.gui = mode == "gui"
self.gui_summary = False
self.full = mode == "full"
self.yes = True # the module entrypoint is always non-interactive
def main(argv=None) -> int:
"""Module entrypoint: ``python -m hermes_cli.uninstall --mode <gui|lite|full>``.
Exists so the desktop app can run the uninstall under a Python interpreter
OUTSIDE the venv being deleted. On Windows, ``lite``/``full`` rmtree the
venv that contains the running ``python.exe`` — and a running .exe is
mandatory-locked, so doing that from the venv's own interpreter half-fails.
The desktop launches this with the system Python + ``PYTHONPATH=<agentRoot>``
so ``import hermes_cli`` resolves from source while the venv is torn down.
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.
"""
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",
)
ns = parser.parse_args(argv)
args = _UninstallArgs(mode=ns.mode)
if args.gui:
run_gui_uninstall(args)
else:
run_uninstall(args)
return 0
if __name__ == "__main__":
sys.exit(main())