From 79c5a381c59c948a7334988657b5bf19c4765a32 Mon Sep 17 00:00:00 2001 From: Teknium Date: Sat, 18 Apr 2026 19:14:55 -0700 Subject: [PATCH] feat(uninstall): offer to remove named profiles when uninstalling from default When `hermes uninstall` runs from the default HERMES_HOME (~/.hermes) and other named profiles exist under ~/.hermes/profiles/, show them in the installation overview and prompt: Also stop and remove these N profile(s)? [y/N] If confirmed, for each named profile we: 1. Shell out to `python -m hermes_cli.main -p gateway stop/uninstall` to stop the gateway and remove its systemd unit or launchd plist (service names + unit paths are derived from HERMES_HOME, so we can't cleanly switch in-process) 2. Remove the ~/.local/bin/ alias wrapper (outside HERMES_HOME) 3. Wipe the profile's HERMES_HOME dir Previously `hermes uninstall` was silently profile-scoped, leaving zombie systemd units at ~/.config/systemd/user/hermes-gateway-.service and zombie HERMES_HOMEs under ~/.hermes/profiles/ whenever a user uninstalled from default with other profiles configured. Prompt only appears when uninstalling from the default root. Uninstalling from within a named profile stays profile-scoped as before. --- hermes_cli/uninstall.py | 130 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 127 insertions(+), 3 deletions(-) diff --git a/hermes_cli/uninstall.py b/hermes_cli/uninstall.py index c9f2734fe..67cea4182 100644 --- a/hermes_cli/uninstall.py +++ b/hermes_cli/uninstall.py @@ -204,6 +204,80 @@ def uninstall_gateway_service(): return stopped_something +def _is_default_hermes_home(hermes_home: Path) -> bool: + """Return True when ``hermes_home`` points at the default (non-profile) root.""" + try: + from hermes_constants import get_default_hermes_root + return hermes_home.resolve() == get_default_hermes_root().resolve() + except Exception: + return False + + +def _discover_named_profiles(): + """Return a list of ``ProfileInfo`` for every non-default profile, or ``[]`` + if profile support is unavailable or nothing is installed beyond the + default root.""" + try: + from hermes_cli.profiles import list_profiles + except Exception: + return [] + try: + return [p for p in list_profiles() if not getattr(p, "is_default", False)] + except Exception as e: + log_warn(f"Could not enumerate profiles: {e}") + return [] + + +def _uninstall_profile(profile) -> None: + """Fully uninstall a single named profile: stop its gateway service, + remove its alias wrapper, and wipe its HERMES_HOME directory. + + We shell out to ``hermes -p gateway stop|uninstall`` because + service names, unit paths, and plist paths are all derived from the + current HERMES_HOME and can't be easily switched in-process. + """ + import sys as _sys + name = profile.name + profile_home = profile.path + + 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. + hermes_invocation = [_sys.executable, "-m", "hermes_cli.main", "--profile", name] + for subcmd in ("stop", "uninstall"): + try: + subprocess.run( + hermes_invocation + ["gateway", subcmd], + capture_output=True, + text=True, + timeout=60, + check=False, + ) + except subprocess.TimeoutExpired: + log_warn(f" Gateway {subcmd} timed out for '{name}'") + except Exception as e: + log_warn(f" Could not run gateway {subcmd} for '{name}': {e}") + + # 2. Remove the wrapper alias script at ~/.local/bin/ (if any). + alias_path = getattr(profile, "alias_path", None) + if alias_path and alias_path.exists(): + try: + alias_path.unlink() + log_success(f" Removed alias {alias_path}") + except Exception as e: + log_warn(f" Could not remove alias {alias_path}: {e}") + + # 3. Wipe the profile's HERMES_HOME directory. + try: + if profile_home.exists(): + shutil.rmtree(profile_home) + log_success(f" Removed {profile_home}") + except Exception as e: + log_warn(f" Could not remove {profile_home}: {e}") + + def run_uninstall(args): """ Run the uninstall process. @@ -214,7 +288,13 @@ def run_uninstall(args): """ project_root = get_project_root() hermes_home = get_hermes_home() - + + # 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. + is_default_profile = _is_default_hermes_home(hermes_home) + named_profiles = _discover_named_profiles() if is_default_profile else [] + print() print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA, Colors.BOLD)) print(color("│ ⚕ Hermes Agent Uninstaller │", Colors.MAGENTA, Colors.BOLD)) @@ -228,6 +308,13 @@ def run_uninstall(args): print(f" Secrets: {hermes_home / '.env'}") print(f" Data: {hermes_home / 'cron/'}, {hermes_home / 'sessions/'}, {hermes_home / 'logs/'}") print() + + if named_profiles: + print(color("Other profiles detected:", Colors.CYAN, Colors.BOLD)) + for p in named_profiles: + running = " (gateway running)" if getattr(p, "gateway_running", False) else "" + print(f" • {p.name}{running}: {p.path}") + print() # Ask for confirmation print(color("Uninstall Options:", Colors.YELLOW, Colors.BOLD)) @@ -254,12 +341,40 @@ def run_uninstall(args): return 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. + remove_profiles = False + if full_uninstall and named_profiles: + print() + print(color("Other profiles will NOT be removed by default.", Colors.YELLOW)) + print(f"Found {len(named_profiles)} named profile(s): " + + ", ".join(p.name for p in named_profiles)) + print() + try: + resp = input(color( + f"Also stop and remove these {len(named_profiles)} profile(s)? [y/N]: ", + Colors.BOLD + )).strip().lower() + except (KeyboardInterrupt, EOFError): + print() + print("Cancelled.") + return + remove_profiles = resp in ("y", "yes") + # Final confirmation print() if full_uninstall: print(color("⚠️ WARNING: This will permanently delete ALL Hermes data!", Colors.RED, Colors.BOLD)) print(color(" Including: configs, API keys, sessions, scheduled jobs, logs", Colors.RED)) + if remove_profiles: + print(color( + f" Plus {len(named_profiles)} profile(s): " + + ", ".join(p.name for p in named_profiles), + Colors.RED + )) else: print("This will remove the Hermes code but keep your configuration and data.") @@ -322,8 +437,17 @@ def run_uninstall(args): log_warn(f"Could not fully remove {project_root}: {e}") log_info("You may need to manually remove it") - # 5. Optionally remove ~/.hermes/ data directory + # 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 will be swept away by the + # rmtree below, but services + alias scripts live OUTSIDE the + # default root and have to be cleaned up explicitly. + if remove_profiles and named_profiles: + for prof in named_profiles: + _uninstall_profile(prof) + log_info("Removing configuration and data...") try: if hermes_home.exists():