From 44bf859c3b456df2e83d8825e3bba8e157b02f78 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Wed, 11 Mar 2026 09:09:58 -0700 Subject: [PATCH 1/3] feat: offer OpenClaw migration during first-time setup wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a new user runs 'hermes setup' for the first time and ~/.openclaw/ exists, the wizard now asks if they want to import their OpenClaw data before API/tool configuration begins. If accepted, the existing migration script from optional-skills/ is loaded dynamically and run with the 'full' preset — importing settings, memories, skills, API keys, and platform configs. Config is reloaded afterward so imported values (like API keys) are available for the remaining setup steps. The migration is only offered on first-time setup (not returning users) and handles errors gracefully without blocking setup completion. Closes #829 --- hermes_cli/setup.py | 101 +++++++ .../test_setup_openclaw_migration.py | 257 ++++++++++++++++++ 2 files changed, 358 insertions(+) create mode 100644 tests/hermes_cli/test_setup_openclaw_migration.py diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index f533a9384..b53b0bf04 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -11,6 +11,8 @@ Config files are stored in ~/.hermes/ for easy access. """ +import importlib.util +import json import logging import os import sys @@ -2076,6 +2078,100 @@ def setup_tools(config: dict, first_install: bool = False): tools_command(first_install=first_install, config=config) +# ============================================================================= +# OpenClaw Migration +# ============================================================================= + + +_OPENCLAW_SCRIPT = ( + PROJECT_ROOT / "optional-skills" / "migration" + / "openclaw-migration" / "scripts" / "openclaw_to_hermes.py" +) + + +def _offer_openclaw_migration(hermes_home: Path) -> bool: + """Detect ~/.openclaw and offer to migrate during first-time setup. + + Returns True if migration ran successfully, False otherwise. + """ + openclaw_dir = Path.home() / ".openclaw" + if not openclaw_dir.is_dir(): + return False + + if not _OPENCLAW_SCRIPT.exists(): + return False + + print() + print_header("OpenClaw Installation Detected") + print_info(f"Found OpenClaw data at {openclaw_dir}") + print_info("Hermes can import your settings, memories, skills, and API keys.") + print() + + if not prompt_yes_no("Would you like to import from OpenClaw?", default=True): + print_info("Skipping migration. You can run it later via the openclaw-migration skill.") + return False + + # Ensure config.yaml exists before migration tries to read it + config_path = get_config_path() + if not config_path.exists(): + save_config(load_config()) + + # Dynamically load the migration script + try: + spec = importlib.util.spec_from_file_location( + "openclaw_to_hermes", _OPENCLAW_SCRIPT + ) + if spec is None or spec.loader is None: + print_warning("Could not load migration script.") + return False + + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + # Run migration with the "full" preset, execute mode, no overwrite + selected = mod.resolve_selected_options(None, None, preset="full") + migrator = mod.Migrator( + source_root=openclaw_dir.resolve(), + target_root=hermes_home.resolve(), + execute=True, + workspace_target=None, + overwrite=False, + migrate_secrets=True, + output_dir=None, + selected_options=selected, + preset_name="full", + ) + report = migrator.migrate() + except Exception as e: + print_warning(f"Migration failed: {e}") + logger.debug("OpenClaw migration error", exc_info=True) + return False + + # Print summary + summary = report.get("summary", {}) + migrated = summary.get("migrated", 0) + skipped = summary.get("skipped", 0) + conflicts = summary.get("conflict", 0) + errors = summary.get("error", 0) + + print() + if migrated: + print_success(f"Imported {migrated} item(s) from OpenClaw.") + if conflicts: + print_info(f"Skipped {conflicts} item(s) that already exist in Hermes.") + if skipped: + print_info(f"Skipped {skipped} item(s) (not found or unchanged).") + if errors: + print_warning(f"{errors} item(s) had errors — check the migration report.") + + output_dir = report.get("output_dir") + if output_dir: + print_info(f"Full report saved to: {output_dir}") + + print_success("Migration complete! Continuing with setup...") + return True + + # ============================================================================= # Main Wizard Orchestrator # ============================================================================= @@ -2242,6 +2338,11 @@ def run_setup_wizard(args): print() return + # Offer OpenClaw migration before configuration begins + if _offer_openclaw_migration(hermes_home): + # Reload config in case migration wrote to it + config = load_config() + # ── Full Setup — run all sections ── print_header("Configuration Location") print_info(f"Config file: {get_config_path()}") diff --git a/tests/hermes_cli/test_setup_openclaw_migration.py b/tests/hermes_cli/test_setup_openclaw_migration.py new file mode 100644 index 000000000..aa93eb5cd --- /dev/null +++ b/tests/hermes_cli/test_setup_openclaw_migration.py @@ -0,0 +1,257 @@ +"""Tests for OpenClaw migration integration in the setup wizard.""" + +from argparse import Namespace +from pathlib import Path +from types import ModuleType +from unittest.mock import MagicMock, patch + +import pytest + +from hermes_cli import setup as setup_mod + + +# --------------------------------------------------------------------------- +# _offer_openclaw_migration — unit tests +# --------------------------------------------------------------------------- + + +class TestOfferOpenclawMigration: + """Test the _offer_openclaw_migration helper in isolation.""" + + def test_skips_when_no_openclaw_dir(self, tmp_path): + """Should return False immediately when ~/.openclaw does not exist.""" + with patch("hermes_cli.setup.Path.home", return_value=tmp_path): + assert setup_mod._offer_openclaw_migration(tmp_path / ".hermes") is False + + def test_skips_when_migration_script_missing(self, tmp_path): + """Should return False when the migration script file is absent.""" + openclaw_dir = tmp_path / ".openclaw" + openclaw_dir.mkdir() + with ( + patch("hermes_cli.setup.Path.home", return_value=tmp_path), + patch.object(setup_mod, "_OPENCLAW_SCRIPT", tmp_path / "nonexistent.py"), + ): + assert setup_mod._offer_openclaw_migration(tmp_path / ".hermes") is False + + def test_skips_when_user_declines(self, tmp_path): + """Should return False when user declines the migration prompt.""" + openclaw_dir = tmp_path / ".openclaw" + openclaw_dir.mkdir() + script = tmp_path / "openclaw_to_hermes.py" + script.write_text("# placeholder") + with ( + patch("hermes_cli.setup.Path.home", return_value=tmp_path), + patch.object(setup_mod, "_OPENCLAW_SCRIPT", script), + patch.object(setup_mod, "prompt_yes_no", return_value=False), + ): + assert setup_mod._offer_openclaw_migration(tmp_path / ".hermes") is False + + def test_runs_migration_when_user_accepts(self, tmp_path): + """Should dynamically load the script and run the Migrator.""" + openclaw_dir = tmp_path / ".openclaw" + openclaw_dir.mkdir() + + # Create a fake hermes home with config + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text("agent:\n max_turns: 90\n") + + # Build a fake migration module + fake_mod = ModuleType("openclaw_to_hermes") + fake_mod.resolve_selected_options = MagicMock(return_value={"soul", "memory"}) + fake_migrator = MagicMock() + fake_migrator.migrate.return_value = { + "summary": {"migrated": 3, "skipped": 1, "conflict": 0, "error": 0}, + "output_dir": str(hermes_home / "migration"), + } + fake_mod.Migrator = MagicMock(return_value=fake_migrator) + + script = tmp_path / "openclaw_to_hermes.py" + script.write_text("# placeholder") + + with ( + patch("hermes_cli.setup.Path.home", return_value=tmp_path), + patch.object(setup_mod, "_OPENCLAW_SCRIPT", script), + patch.object(setup_mod, "prompt_yes_no", return_value=True), + patch.object(setup_mod, "get_config_path", return_value=config_path), + patch("importlib.util.spec_from_file_location") as mock_spec_fn, + ): + # Wire up the fake module loading + mock_spec = MagicMock() + mock_spec.loader = MagicMock() + mock_spec_fn.return_value = mock_spec + + def exec_module(mod): + mod.resolve_selected_options = fake_mod.resolve_selected_options + mod.Migrator = fake_mod.Migrator + + mock_spec.loader.exec_module = exec_module + + result = setup_mod._offer_openclaw_migration(hermes_home) + + assert result is True + fake_mod.resolve_selected_options.assert_called_once_with( + None, None, preset="full" + ) + fake_mod.Migrator.assert_called_once() + call_kwargs = fake_mod.Migrator.call_args[1] + assert call_kwargs["execute"] is True + assert call_kwargs["overwrite"] is False + assert call_kwargs["migrate_secrets"] is True + assert call_kwargs["preset_name"] == "full" + fake_migrator.migrate.assert_called_once() + + def test_handles_migration_error_gracefully(self, tmp_path): + """Should catch exceptions and return False.""" + openclaw_dir = tmp_path / ".openclaw" + openclaw_dir.mkdir() + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text("") + + script = tmp_path / "openclaw_to_hermes.py" + script.write_text("# placeholder") + + with ( + patch("hermes_cli.setup.Path.home", return_value=tmp_path), + patch.object(setup_mod, "_OPENCLAW_SCRIPT", script), + patch.object(setup_mod, "prompt_yes_no", return_value=True), + patch.object(setup_mod, "get_config_path", return_value=config_path), + patch( + "importlib.util.spec_from_file_location", + side_effect=RuntimeError("boom"), + ), + ): + result = setup_mod._offer_openclaw_migration(hermes_home) + + assert result is False + + def test_creates_config_if_missing(self, tmp_path): + """Should bootstrap config.yaml before running migration.""" + openclaw_dir = tmp_path / ".openclaw" + openclaw_dir.mkdir() + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + # config does NOT exist yet + + script = tmp_path / "openclaw_to_hermes.py" + script.write_text("# placeholder") + + with ( + patch("hermes_cli.setup.Path.home", return_value=tmp_path), + patch.object(setup_mod, "_OPENCLAW_SCRIPT", script), + patch.object(setup_mod, "prompt_yes_no", return_value=True), + patch.object(setup_mod, "get_config_path", return_value=config_path), + patch.object(setup_mod, "load_config", return_value={"agent": {}}) as mock_load, + patch.object(setup_mod, "save_config") as mock_save, + patch( + "importlib.util.spec_from_file_location", + side_effect=RuntimeError("stop early"), + ), + ): + setup_mod._offer_openclaw_migration(hermes_home) + + # save_config should have been called to bootstrap the file + mock_save.assert_called_once_with({"agent": {}}) + + +# --------------------------------------------------------------------------- +# Integration with run_setup_wizard — first-time flow +# --------------------------------------------------------------------------- + + +def _first_time_args() -> Namespace: + return Namespace( + section=None, + non_interactive=False, + reset=False, + ) + + +class TestSetupWizardOpenclawIntegration: + """Verify _offer_openclaw_migration is called during first-time setup.""" + + def test_migration_offered_during_first_time_setup(self, tmp_path): + """On first-time setup, _offer_openclaw_migration should be called.""" + args = _first_time_args() + + with ( + patch.object(setup_mod, "ensure_hermes_home"), + patch.object(setup_mod, "load_config", return_value={}), + patch.object(setup_mod, "get_hermes_home", return_value=tmp_path), + patch.object(setup_mod, "get_env_value", return_value=""), + patch("hermes_cli.auth.get_active_provider", return_value=None), + # User presses Enter to start + patch("builtins.input", return_value=""), + # Mock the migration offer + patch.object( + setup_mod, "_offer_openclaw_migration", return_value=False + ) as mock_migration, + # Mock the actual setup sections so they don't run + patch.object(setup_mod, "setup_model_provider"), + patch.object(setup_mod, "setup_terminal_backend"), + patch.object(setup_mod, "setup_agent_settings"), + patch.object(setup_mod, "setup_gateway"), + patch.object(setup_mod, "setup_tools"), + patch.object(setup_mod, "save_config"), + patch.object(setup_mod, "_print_setup_summary"), + ): + setup_mod.run_setup_wizard(args) + + mock_migration.assert_called_once_with(tmp_path) + + def test_migration_reloads_config_on_success(self, tmp_path): + """When migration returns True, config should be reloaded.""" + args = _first_time_args() + call_order = [] + + def tracking_load_config(): + call_order.append("load_config") + return {} + + with ( + patch.object(setup_mod, "ensure_hermes_home"), + patch.object(setup_mod, "load_config", side_effect=tracking_load_config), + patch.object(setup_mod, "get_hermes_home", return_value=tmp_path), + patch.object(setup_mod, "get_env_value", return_value=""), + patch("hermes_cli.auth.get_active_provider", return_value=None), + patch("builtins.input", return_value=""), + patch.object(setup_mod, "_offer_openclaw_migration", return_value=True), + patch.object(setup_mod, "setup_model_provider"), + patch.object(setup_mod, "setup_terminal_backend"), + patch.object(setup_mod, "setup_agent_settings"), + patch.object(setup_mod, "setup_gateway"), + patch.object(setup_mod, "setup_tools"), + patch.object(setup_mod, "save_config"), + patch.object(setup_mod, "_print_setup_summary"), + ): + setup_mod.run_setup_wizard(args) + + # load_config called twice: once at start, once after migration + assert call_order.count("load_config") == 2 + + def test_migration_not_offered_for_existing_install(self, tmp_path): + """Returning users should not see the migration prompt.""" + args = _first_time_args() + + with ( + patch.object(setup_mod, "ensure_hermes_home"), + patch.object(setup_mod, "load_config", return_value={}), + patch.object(setup_mod, "get_hermes_home", return_value=tmp_path), + patch.object( + setup_mod, "get_env_value", + side_effect=lambda k: "sk-xxx" if k == "OPENROUTER_API_KEY" else "", + ), + patch("hermes_cli.auth.get_active_provider", return_value=None), + # Returning user picks "Exit" + patch.object(setup_mod, "prompt_choice", return_value=9), + patch.object( + setup_mod, "_offer_openclaw_migration", return_value=False + ) as mock_migration, + ): + setup_mod.run_setup_wizard(args) + + mock_migration.assert_not_called() From 4f427167ac4967e079f5e5a2dd27538fef1dcd45 Mon Sep 17 00:00:00 2001 From: kshitij <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 12 Mar 2026 02:49:29 +0530 Subject: [PATCH 2/3] chore: clean OpenClaw migration follow-up --- hermes_cli/setup.py | 13 +++++++++---- tests/hermes_cli/test_setup_openclaw_migration.py | 8 +++----- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index b53b0bf04..69029545b 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -12,7 +12,6 @@ """ import importlib.util -import json import logging import os import sys @@ -2084,8 +2083,12 @@ def setup_tools(config: dict, first_install: bool = False): _OPENCLAW_SCRIPT = ( - PROJECT_ROOT / "optional-skills" / "migration" - / "openclaw-migration" / "scripts" / "openclaw_to_hermes.py" + PROJECT_ROOT + / "optional-skills" + / "migration" + / "openclaw-migration" + / "scripts" + / "openclaw_to_hermes.py" ) @@ -2108,7 +2111,9 @@ def _offer_openclaw_migration(hermes_home: Path) -> bool: print() if not prompt_yes_no("Would you like to import from OpenClaw?", default=True): - print_info("Skipping migration. You can run it later via the openclaw-migration skill.") + print_info( + "Skipping migration. You can run it later via the openclaw-migration skill." + ) return False # Ensure config.yaml exists before migration tries to read it diff --git a/tests/hermes_cli/test_setup_openclaw_migration.py b/tests/hermes_cli/test_setup_openclaw_migration.py index aa93eb5cd..98c830b4d 100644 --- a/tests/hermes_cli/test_setup_openclaw_migration.py +++ b/tests/hermes_cli/test_setup_openclaw_migration.py @@ -1,12 +1,9 @@ """Tests for OpenClaw migration integration in the setup wizard.""" from argparse import Namespace -from pathlib import Path from types import ModuleType from unittest.mock import MagicMock, patch -import pytest - from hermes_cli import setup as setup_mod @@ -145,7 +142,7 @@ def test_creates_config_if_missing(self, tmp_path): patch.object(setup_mod, "_OPENCLAW_SCRIPT", script), patch.object(setup_mod, "prompt_yes_no", return_value=True), patch.object(setup_mod, "get_config_path", return_value=config_path), - patch.object(setup_mod, "load_config", return_value={"agent": {}}) as mock_load, + patch.object(setup_mod, "load_config", return_value={"agent": {}}), patch.object(setup_mod, "save_config") as mock_save, patch( "importlib.util.spec_from_file_location", @@ -242,7 +239,8 @@ def test_migration_not_offered_for_existing_install(self, tmp_path): patch.object(setup_mod, "load_config", return_value={}), patch.object(setup_mod, "get_hermes_home", return_value=tmp_path), patch.object( - setup_mod, "get_env_value", + setup_mod, + "get_env_value", side_effect=lambda k: "sk-xxx" if k == "OPENROUTER_API_KEY" else "", ), patch("hermes_cli.auth.get_active_provider", return_value=None), From 07126394410075a4a8c6bc98ad69b50c1b371425 Mon Sep 17 00:00:00 2001 From: kshitij <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 12 Mar 2026 02:56:36 +0530 Subject: [PATCH 3/3] test: verify reloaded config drives setup after migration --- .../test_setup_openclaw_migration.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/hermes_cli/test_setup_openclaw_migration.py b/tests/hermes_cli/test_setup_openclaw_migration.py index 98c830b4d..344079aa6 100644 --- a/tests/hermes_cli/test_setup_openclaw_migration.py +++ b/tests/hermes_cli/test_setup_openclaw_migration.py @@ -230,6 +230,35 @@ def tracking_load_config(): # load_config called twice: once at start, once after migration assert call_order.count("load_config") == 2 + def test_reloaded_config_flows_into_remaining_setup_sections(self, tmp_path): + args = _first_time_args() + initial_config = {} + reloaded_config = {"model": {"provider": "openrouter"}} + + with ( + patch.object(setup_mod, "ensure_hermes_home"), + patch.object( + setup_mod, + "load_config", + side_effect=[initial_config, reloaded_config], + ), + patch.object(setup_mod, "get_hermes_home", return_value=tmp_path), + patch.object(setup_mod, "get_env_value", return_value=""), + patch("hermes_cli.auth.get_active_provider", return_value=None), + patch("builtins.input", return_value=""), + patch.object(setup_mod, "_offer_openclaw_migration", return_value=True), + patch.object(setup_mod, "setup_model_provider") as setup_model_provider, + patch.object(setup_mod, "setup_terminal_backend"), + patch.object(setup_mod, "setup_agent_settings"), + patch.object(setup_mod, "setup_gateway"), + patch.object(setup_mod, "setup_tools"), + patch.object(setup_mod, "save_config"), + patch.object(setup_mod, "_print_setup_summary"), + ): + setup_mod.run_setup_wizard(args) + + setup_model_provider.assert_called_once_with(reloaded_config) + def test_migration_not_offered_for_existing_install(self, tmp_path): """Returning users should not see the migration prompt.""" args = _first_time_args()