feat: add uninstall dry-run mode

Port from qwibitai/nanoclaw#2719: let operators preview the uninstall plan without stopping services or deleting files.
This commit is contained in:
Teknium 2026-06-14 17:12:08 -07:00
parent 1deeaf71ab
commit 491689784e
3 changed files with 85 additions and 0 deletions

View file

@ -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)

View file

@ -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,

View file

@ -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