From d8c5573ffe5de2b27cf9e14a41bcdbd9d3785500 Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:11:36 -0600 Subject: [PATCH] fix(profiles): migrate Honcho host on rename --- hermes_cli/profiles.py | 60 ++++++++++++++++++++++++++++- tests/hermes_cli/test_profiles.py | 63 +++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index bf6de16dff..bf5d79b0b8 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -954,6 +954,59 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path: # Rename # --------------------------------------------------------------------------- +def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) -> None: + """Rename Honcho host blocks for a renamed profile without changing peers.""" + old_host = f"hermes.{old_name}" + new_host = f"hermes.{new_name}" + + candidates = [ + new_dir / "honcho.json", + _get_default_hermes_home() / "honcho.json", + Path.home() / ".honcho" / "config.json", + ] + + seen: set[Path] = set() + for path in candidates: + try: + resolved = path.resolve() + except OSError: + resolved = path + if resolved in seen or not path.is_file(): + continue + seen.add(resolved) + + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + + hosts = raw.get("hosts") + if not isinstance(hosts, dict) or old_host not in hosts: + continue + + if new_host in hosts: + print(f"⚠ Honcho host block not migrated: {new_host} already exists in {path}") + continue + + block = hosts[old_host] + if isinstance(block, dict) and "aiPeer" not in block: + bare = old_host.split(".", 1)[1] if "." in old_host else old_host + block["aiPeer"] = bare + hosts[new_host] = hosts.pop(old_host) + tmp = path.with_suffix(path.suffix + ".tmp") + try: + tmp.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + tmp.replace(path) + except OSError: + try: + tmp.unlink(missing_ok=True) + except OSError: + pass + continue + + print(f"✓ Honcho host updated: {old_host} → {new_host}") + + def rename_profile(old_name: str, new_name: str) -> Path: """Rename a profile: directory, wrapper script, service, active_profile. @@ -984,7 +1037,10 @@ def rename_profile(old_name: str, new_name: str) -> Path: old_dir.rename(new_dir) print(f"✓ Renamed {old_dir.name} → {new_dir.name}") - # 3. Update wrapper script + # 3. Update profile-scoped Honcho host blocks, preserving aiPeer identity + _migrate_honcho_profile_host(old_name, new_name, new_dir) + + # 4. Update wrapper script remove_wrapper_script(old_name) collision = check_alias_collision(new_name) if not collision: @@ -993,7 +1049,7 @@ def rename_profile(old_name: str, new_name: str) -> Path: else: print(f"⚠ Cannot create alias '{new_name}' — {collision}") - # 4. Update active_profile if it pointed to old name + # 5. Update active_profile if it pointed to old name try: if get_active_profile() == old_name: set_active_profile(new_name) diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index 7e181c1a88..b8876a764c 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -384,6 +384,69 @@ class TestRenameProfile: assert new_dir.is_dir() assert new_dir == tmp_path / ".hermes" / "profiles" / "newname" + def test_renames_root_honcho_host_without_changing_ai_peer(self, profile_env): + tmp_path = profile_env + create_profile("ssi_health", no_alias=True) + honcho_path = tmp_path / ".hermes" / "honcho.json" + honcho_path.write_text(json.dumps({ + "hosts": { + "hermes.ssi_health": { + "recallMode": "hybrid", + "writeFrequency": "async", + "sessionStrategy": "per-session", + "saveMessages": True, + "peerName": "user-peer", + "aiPeer": "ssi_health", + "workspace": "hermes", + "enabled": True, + } + } + })) + + with patch("hermes_cli.profiles.check_alias_collision", return_value="skip"): + rename_profile("ssi_health", "heimdall") + + cfg = json.loads(honcho_path.read_text()) + assert "hermes.ssi_health" not in cfg["hosts"] + assert cfg["hosts"]["hermes.heimdall"]["aiPeer"] == "ssi_health" + assert cfg["hosts"]["hermes.heimdall"]["peerName"] == "user-peer" + + def test_pins_ai_peer_when_absent_on_honcho_host_rename(self, profile_env): + tmp_path = profile_env + create_profile("ssi_health", no_alias=True) + honcho_path = tmp_path / ".hermes" / "honcho.json" + honcho_path.write_text(json.dumps({ + "hosts": { + "hermes.ssi_health": {"workspace": "hermes", "enabled": True} + } + })) + + with patch("hermes_cli.profiles.check_alias_collision", return_value="skip"): + rename_profile("ssi_health", "heimdall") + + cfg = json.loads(honcho_path.read_text()) + assert "hermes.ssi_health" not in cfg["hosts"] + assert cfg["hosts"]["hermes.heimdall"]["aiPeer"] == "ssi_health" + assert cfg["hosts"]["hermes.heimdall"]["workspace"] == "hermes" + + def test_does_not_overwrite_existing_honcho_host_on_rename(self, profile_env): + tmp_path = profile_env + create_profile("ssi_health", no_alias=True) + honcho_path = tmp_path / ".hermes" / "honcho.json" + honcho_path.write_text(json.dumps({ + "hosts": { + "hermes.ssi_health": {"aiPeer": "ssi_health"}, + "hermes.heimdall": {"aiPeer": "heimdall"}, + } + })) + + with patch("hermes_cli.profiles.check_alias_collision", return_value="skip"): + rename_profile("ssi_health", "heimdall") + + cfg = json.loads(honcho_path.read_text()) + assert cfg["hosts"]["hermes.ssi_health"]["aiPeer"] == "ssi_health" + assert cfg["hosts"]["hermes.heimdall"]["aiPeer"] == "heimdall" + def test_default_raises_value_error(self, profile_env): with pytest.raises(ValueError, match="default"): rename_profile("default", "newname")