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:
@@ -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
@@ -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
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user