diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index f533a9384..69029545b 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -11,6 +11,7 @@ Config files are stored in ~/.hermes/ for easy access. """ +import importlib.util import logging import os import sys @@ -2076,6 +2077,106 @@ 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 +2343,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..344079aa6 --- /dev/null +++ b/tests/hermes_cli/test_setup_openclaw_migration.py @@ -0,0 +1,284 @@ +"""Tests for OpenClaw migration integration in the setup wizard.""" + +from argparse import Namespace +from types import ModuleType +from unittest.mock import MagicMock, patch + +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": {}}), + 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_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() + + 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()