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
+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())