From a829e04d6201534a313d940347e9f153f3b29b4b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:30:23 -0700 Subject: [PATCH] fix: migrate cloned profile configs (#46345) --- hermes_cli/profiles.py | 39 +++++++++++++++++++++++++++++ tests/hermes_cli/test_profiles.py | 28 ++++++++++++++++++--- tests/hermes_cli/test_web_server.py | 11 +++++++- 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index e4c0182cd..ea1212de6 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -455,6 +455,37 @@ def remove_wrapper_script(name: str) -> bool: return False +def _migrate_profile_config_if_outdated(profile_dir: Path) -> None: + """Bring a copied profile config.yaml up to the current schema. + + Profile creation can clone a config file that predates schema tracking (no + ``_config_version``) or that is simply older than the running Hermes. If we + leave it untouched, the first desktop/doctor view of the new profile shows a + scary ``v0 → latest`` warning even though we just created the profile. Scope + the normal migration pipeline to the new profile and keep it non-interactive. + """ + config_path = profile_dir / "config.yaml" + if not config_path.exists(): + return + + try: + from hermes_constants import reset_hermes_home_override, set_hermes_home_override + from hermes_cli.config import check_config_version, migrate_config + + token = set_hermes_home_override(str(profile_dir)) + try: + current_ver, latest_ver = check_config_version() + if current_ver < latest_ver: + migrate_config(interactive=False, quiet=True) + finally: + reset_hermes_home_override(token) + except Exception: + # Profile creation should not fail because an old copied config could + # not be migrated. The next `hermes doctor --fix` can still surface the + # detailed error in the target profile. + pass + + def find_alias_for_profile(profile_name: str) -> Optional[str]: """Return the alias name of the wrapper that activates *profile_name*, or None. @@ -910,6 +941,14 @@ def create_profile( except OSError: pass # best-effort — the feature still works via the empty skills/ dir + # Cloned configs can be older than the running Hermes (or predate schema + # tracking entirely). Migrate config-only clones immediately so + # desktop/status surfaces don't warn that a just-created profile is + # v0/outdated. Leave --clone-all snapshots byte-for-byte apart from the + # explicit runtime/history stripping above. + if not clone_all: + _migrate_profile_config_if_outdated(profile_dir) + # Persist description if the caller provided one. Done last so a # partial-create failure doesn't strand a description file in an # incomplete profile. diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index f700e42b1..1ea1845d9 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -12,6 +12,7 @@ from pathlib import Path from unittest.mock import patch, MagicMock import pytest +import yaml from hermes_cli.profiles import ( normalize_profile_name, @@ -35,6 +36,7 @@ from hermes_cli.profiles import ( NO_BUNDLED_SKILLS_MARKER, backfill_profile_envs, ) +from hermes_cli.config import DEFAULT_CONFIG # --------------------------------------------------------------------------- @@ -206,10 +208,26 @@ class TestCreateProfile: profile_dir = create_profile("coder", clone_config=True, no_alias=True) - assert (profile_dir / "config.yaml").read_text() == "model: test" - assert (profile_dir / ".env").read_text() == "KEY=val" + cloned_config = yaml.safe_load((profile_dir / "config.yaml").read_text()) + assert cloned_config["_config_version"] == DEFAULT_CONFIG["_config_version"] + assert cloned_config["model"] == "test" + assert (profile_dir / ".env").read_text().strip() == "KEY=val" assert (profile_dir / "SOUL.md").read_text() == "Be helpful." + def test_clone_config_migrates_legacy_config_version(self, profile_env): + tmp_path = profile_env + default_home = tmp_path / ".hermes" + (default_home / "config.yaml").write_text( + "model:\n provider: openrouter\n", + encoding="utf-8", + ) + + profile_dir = create_profile("coder", clone_config=True, no_alias=True) + cloned_config = yaml.safe_load((profile_dir / "config.yaml").read_text()) + + assert cloned_config["_config_version"] == DEFAULT_CONFIG["_config_version"] + assert cloned_config["model"]["provider"] == "openrouter" + def test_clone_config_copies_source_skills(self, profile_env): tmp_path = profile_env default_home = tmp_path / ".hermes" @@ -1453,8 +1471,10 @@ class TestEdgeCases: target_dir = create_profile( "target", clone_from="source", clone_config=True, no_alias=True, ) - assert (target_dir / "config.yaml").read_text() == "model: cloned" - assert (target_dir / ".env").read_text() == "SECRET=yes" + cloned_config = yaml.safe_load((target_dir / "config.yaml").read_text()) + assert cloned_config["_config_version"] == DEFAULT_CONFIG["_config_version"] + assert cloned_config["model"] == "cloned" + assert (target_dir / ".env").read_text().strip() == "SECRET=yes" def test_delete_clears_active_profile(self, profile_env): """Deleting the active profile resets active to default.""" diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index b042c7621..870474327 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -8,11 +8,13 @@ from types import SimpleNamespace from unittest.mock import patch, MagicMock import pytest +import yaml from hermes_cli.config import ( reload_env, redact_key, OPTIONAL_ENV_VARS, + DEFAULT_CONFIG, ) @@ -2708,6 +2710,10 @@ class TestNewEndpoints: import hermes_cli.profiles as profiles_mod monkeypatch.setattr(profiles_mod, "create_wrapper_script", lambda name: None) + (get_hermes_home() / "config.yaml").write_text( + "model:\n provider: openrouter\n", + encoding="utf-8", + ) default_skill = get_hermes_home() / "skills" / "custom" / "new-skill" default_skill.mkdir(parents=True) (default_skill / "SKILL.md").write_text("---\nname: new-skill\n---\n", encoding="utf-8") @@ -2718,8 +2724,11 @@ class TestNewEndpoints: ) assert resp.status_code == 200 - cloned_skill = get_hermes_home() / "profiles" / "cloned" / "skills" / "custom" / "new-skill" / "SKILL.md" + cloned_root = get_hermes_home() / "profiles" / "cloned" + cloned_skill = cloned_root / "skills" / "custom" / "new-skill" / "SKILL.md" assert cloned_skill.exists() + cloned_config = yaml.safe_load((cloned_root / "config.yaml").read_text(encoding="utf-8")) + assert cloned_config["_config_version"] == DEFAULT_CONFIG["_config_version"] profiles = {p["name"]: p for p in self.client.get("/api/profiles").json()["profiles"]} assert profiles["cloned"]["skill_count"] == 1