diff --git a/hermes_cli/subcommands/uninstall.py b/hermes_cli/subcommands/uninstall.py index 1250af3e04d..f746a8906a6 100644 --- a/hermes_cli/subcommands/uninstall.py +++ b/hermes_cli/subcommands/uninstall.py @@ -38,4 +38,9 @@ def build_uninstall_parser(subparsers, *, cmd_uninstall: Callable) -> None: uninstall_parser.add_argument( "--yes", "-y", action="store_true", help="Skip confirmation prompts" ) + uninstall_parser.add_argument( + "--dry-run", + action="store_true", + help="Print what uninstall would remove without changing anything", + ) uninstall_parser.set_defaults(func=cmd_uninstall) diff --git a/hermes_cli/uninstall.py b/hermes_cli/uninstall.py index 9b53500734b..6832d3d5607 100644 --- a/hermes_cli/uninstall.py +++ b/hermes_cli/uninstall.py @@ -575,6 +575,14 @@ def run_uninstall(args): project_root = get_project_root() hermes_home = get_hermes_home() + if bool(getattr(args, "dry_run", False)): + _print_uninstall_dry_run( + project_root=project_root, + hermes_home=hermes_home, + full_uninstall=bool(getattr(args, "full", False)), + ) + return + # 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. @@ -704,6 +712,30 @@ def run_uninstall(args): ) +def _print_uninstall_dry_run(*, project_root: Path, hermes_home: Path, full_uninstall: bool) -> None: + """Print the uninstall plan without stopping services or deleting files.""" + print() + print(color("Dry run: no files, services, or environment entries will be changed.", Colors.CYAN, Colors.BOLD)) + print() + print(color("Would inspect/remove:", Colors.YELLOW, Colors.BOLD)) + print(" • Gateway services and standalone gateway processes") + print(" • Hermes PATH entries from shell configs / Windows User PATH") + print(" • Hermes wrapper scripts and Hermes-managed node/npm/npx symlinks") + print(" • Desktop Chat GUI artifacts") + print(f" • Code checkout: {project_root}") + if full_uninstall: + print(f" • Hermes config/data: {hermes_home}") + if _is_default_hermes_home(hermes_home): + profiles = _discover_named_profiles() + if profiles: + print(" • Named profiles (interactive uninstall asks before removing):") + for prof in profiles: + print(f" - {prof.name}: {prof.path}") + else: + print(f" • Keep Hermes config/data: {hermes_home}") + print() + + def _perform_uninstall( *, project_root: Path, diff --git a/tests/hermes_cli/test_uninstall_dry_run.py b/tests/hermes_cli/test_uninstall_dry_run.py new file mode 100644 index 00000000000..ec74c8e5c18 --- /dev/null +++ b/tests/hermes_cli/test_uninstall_dry_run.py @@ -0,0 +1,48 @@ +from pathlib import Path +from types import SimpleNamespace + +from hermes_cli import uninstall + + +def test_dry_run_prints_plan_without_mutating(monkeypatch, tmp_path, capsys): + project_root = tmp_path / "hermes-agent" + hermes_home = tmp_path / ".hermes" + project_root.mkdir() + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text("model: {}\n") + + called = False + + def _fail_if_called(**kwargs): + nonlocal called + called = True + + monkeypatch.setattr(uninstall, "get_project_root", lambda: project_root) + monkeypatch.setattr(uninstall, "get_hermes_home", lambda: hermes_home) + monkeypatch.setattr(uninstall, "_is_default_hermes_home", lambda home: False) + monkeypatch.setattr(uninstall, "_discover_named_profiles", lambda: []) + monkeypatch.setattr(uninstall, "_perform_uninstall", _fail_if_called) + + uninstall.run_uninstall(SimpleNamespace(dry_run=True, yes=True, full=True)) + + output = capsys.readouterr().out + assert called is False + assert "Dry run" in output + assert str(project_root) in output + assert str(hermes_home) in output + assert project_root.exists() + assert hermes_home.exists() + + +def test_build_uninstall_parser_accepts_dry_run(): + import argparse + from hermes_cli.subcommands.uninstall import build_uninstall_parser + + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command") + build_uninstall_parser(subparsers, cmd_uninstall=lambda args: args) + + args = parser.parse_args(["uninstall", "--dry-run", "--full"]) + + assert args.dry_run is True + assert args.full is True