diff --git a/README.md b/README.md index 3e41fbf7..9f08b4bb 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ hermes tools # Configure which tools are enabled hermes config set # Set individual config values hermes gateway # Start the messaging gateway (Telegram, Discord, etc.) hermes setup # Run the full setup wizard (configures everything at once) +hermes claw migrate # Migrate from OpenClaw (if coming from OpenClaw) hermes update # Update to the latest version hermes doctor # Diagnose any issues ``` @@ -87,6 +88,35 @@ All documentation lives at **[hermes-agent.nousresearch.com/docs](https://hermes --- +## Migrating from OpenClaw + +If you're coming from OpenClaw, Hermes can automatically import your settings, memories, skills, and API keys. + +**During first-time setup:** The setup wizard (`hermes setup`) automatically detects `~/.openclaw` and offers to migrate before configuration begins. + +**Anytime after install:** + +```bash +hermes claw migrate # Interactive migration (full preset) +hermes claw migrate --dry-run # Preview what would be migrated +hermes claw migrate --preset user-data # Migrate without secrets +hermes claw migrate --overwrite # Overwrite existing conflicts +``` + +What gets imported: +- **SOUL.md** — persona file +- **Memories** — MEMORY.md and USER.md entries +- **Skills** — user-created skills → `~/.hermes/skills/openclaw-imports/` +- **Command allowlist** — approval patterns +- **Messaging settings** — platform configs, allowed users, working directory +- **API keys** — allowlisted secrets (Telegram, OpenRouter, OpenAI, Anthropic, ElevenLabs) +- **TTS assets** — workspace audio files +- **Workspace instructions** — AGENTS.md (with `--workspace-target`) + +See `hermes claw migrate --help` for all options, or use the `openclaw-migration` skill for an interactive agent-guided migration with dry-run previews. + +--- + ## Contributing We welcome contributions! See the [Contributing Guide](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) for development setup, code style, and PR process. diff --git a/docs/migration/openclaw.md b/docs/migration/openclaw.md new file mode 100644 index 00000000..c3aef460 --- /dev/null +++ b/docs/migration/openclaw.md @@ -0,0 +1,110 @@ +# Migrating from OpenClaw to Hermes Agent + +This guide covers how to import your OpenClaw settings, memories, skills, and API keys into Hermes Agent. + +## Three Ways to Migrate + +### 1. Automatic (during first-time setup) + +When you run `hermes setup` for the first time and Hermes detects `~/.openclaw`, it automatically offers to import your OpenClaw data before configuration begins. Just accept the prompt and everything is handled for you. + +### 2. CLI Command (quick, scriptable) + +```bash +hermes claw migrate # Full migration with confirmation prompt +hermes claw migrate --dry-run # Preview what would happen +hermes claw migrate --preset user-data # Migrate without API keys/secrets +hermes claw migrate --yes # Skip confirmation prompt +``` + +**All options:** + +| Flag | Description | +|------|-------------| +| `--source PATH` | Path to OpenClaw directory (default: `~/.openclaw`) | +| `--dry-run` | Preview only — no files are modified | +| `--preset {user-data,full}` | Migration preset (default: `full`). `user-data` excludes secrets | +| `--overwrite` | Overwrite existing files (default: skip conflicts) | +| `--migrate-secrets` | Include allowlisted secrets (auto-enabled with `full` preset) | +| `--workspace-target PATH` | Copy workspace instructions (AGENTS.md) to this absolute path | +| `--skill-conflict {skip,overwrite,rename}` | How to handle skill name conflicts (default: `skip`) | +| `--yes`, `-y` | Skip confirmation prompts | + +### 3. Agent-Guided (interactive, with previews) + +Ask the agent to run the migration for you: + +``` +> Migrate my OpenClaw setup to Hermes +``` + +The agent will use the `openclaw-migration` skill to: +1. Run a dry-run first to preview changes +2. Ask about conflict resolution (SOUL.md, skills, etc.) +3. Let you choose between `user-data` and `full` presets +4. Execute the migration with your choices +5. Print a detailed summary of what was migrated + +## What Gets Migrated + +### `user-data` preset +| Item | Source | Destination | +|------|--------|-------------| +| SOUL.md | `~/.openclaw/workspace/SOUL.md` | `~/.hermes/SOUL.md` | +| Memory entries | `~/.openclaw/workspace/MEMORY.md` | `~/.hermes/memories/MEMORY.md` | +| User profile | `~/.openclaw/workspace/USER.md` | `~/.hermes/memories/USER.md` | +| Skills | `~/.openclaw/workspace/skills/` | `~/.hermes/skills/openclaw-imports/` | +| Command allowlist | `~/.openclaw/workspace/exec_approval_patterns.yaml` | Merged into `~/.hermes/config.yaml` | +| Messaging settings | `~/.openclaw/config.yaml` (TELEGRAM_ALLOWED_USERS, MESSAGING_CWD) | `~/.hermes/.env` | +| TTS assets | `~/.openclaw/workspace/tts/` | `~/.hermes/tts/` | + +### `full` preset (adds to `user-data`) +| Item | Source | Destination | +|------|--------|-------------| +| Telegram bot token | `~/.openclaw/config.yaml` | `~/.hermes/.env` | +| OpenRouter API key | `~/.openclaw/.env` or config | `~/.hermes/.env` | +| OpenAI API key | `~/.openclaw/.env` or config | `~/.hermes/.env` | +| Anthropic API key | `~/.openclaw/.env` or config | `~/.hermes/.env` | +| ElevenLabs API key | `~/.openclaw/.env` or config | `~/.hermes/.env` | + +Only these 6 allowlisted secrets are ever imported. Other credentials are skipped and reported. + +## Conflict Handling + +By default, the migration **will not overwrite** existing Hermes data: + +- **SOUL.md** — skipped if one already exists in `~/.hermes/` +- **Memory entries** — skipped if memories already exist (to avoid duplicates) +- **Skills** — skipped if a skill with the same name already exists +- **API keys** — skipped if the key is already set in `~/.hermes/.env` + +To overwrite conflicts, use `--overwrite`. The migration creates backups before overwriting. + +For skills, you can also use `--skill-conflict rename` to import conflicting skills under a new name (e.g., `skill-name-imported`). + +## Migration Report + +Every migration (including dry runs) produces a report showing: +- **Migrated items** — what was successfully imported +- **Conflicts** — items skipped because they already exist +- **Skipped items** — items not found in the source +- **Errors** — items that failed to import + +For execute runs, the full report is saved to `~/.hermes/migration/openclaw//`. + +## Troubleshooting + +### "OpenClaw directory not found" +The migration looks for `~/.openclaw` by default. If your OpenClaw is installed elsewhere, use `--source`: +```bash +hermes claw migrate --source /path/to/.openclaw +``` + +### "Migration script not found" +The migration script ships with Hermes Agent. If you installed via pip (not git clone), the `optional-skills/` directory may not be present. Install the skill from the Skills Hub: +```bash +hermes skills install openclaw-migration +``` + +### Memory overflow +If your OpenClaw MEMORY.md or USER.md exceeds Hermes' character limits, excess entries are exported to an overflow file in the migration report directory. You can manually review and add the most important ones. diff --git a/hermes_cli/claw.py b/hermes_cli/claw.py new file mode 100644 index 00000000..5de56890 --- /dev/null +++ b/hermes_cli/claw.py @@ -0,0 +1,296 @@ +"""hermes claw — OpenClaw migration commands. + +Usage: + hermes claw migrate # Interactive migration from ~/.openclaw + hermes claw migrate --dry-run # Preview what would be migrated + hermes claw migrate --preset full --overwrite # Full migration, overwrite conflicts +""" + +import importlib.util +import logging +import sys +from pathlib import Path + +from hermes_cli.config import get_hermes_home, get_config_path, load_config, save_config +from hermes_cli.setup import ( + Colors, + color, + print_header, + print_info, + print_success, + print_warning, + print_error, + prompt_yes_no, + prompt_choice, +) + +logger = logging.getLogger(__name__) + +PROJECT_ROOT = Path(__file__).parent.parent.resolve() + +_OPENCLAW_SCRIPT = ( + PROJECT_ROOT + / "optional-skills" + / "migration" + / "openclaw-migration" + / "scripts" + / "openclaw_to_hermes.py" +) + +# Fallback: user may have installed the skill from the Hub +_OPENCLAW_SCRIPT_INSTALLED = ( + get_hermes_home() + / "skills" + / "migration" + / "openclaw-migration" + / "scripts" + / "openclaw_to_hermes.py" +) + + +def _find_migration_script() -> Path | None: + """Find the openclaw_to_hermes.py script in known locations.""" + for candidate in [_OPENCLAW_SCRIPT, _OPENCLAW_SCRIPT_INSTALLED]: + if candidate.exists(): + return candidate + return None + + +def _load_migration_module(script_path: Path): + """Dynamically load the migration script as a module.""" + spec = importlib.util.spec_from_file_location("openclaw_to_hermes", script_path) + if spec is None or spec.loader is None: + return None + mod = importlib.util.module_from_spec(spec) + # Register in sys.modules so @dataclass can resolve the module + # (Python 3.11+ requires this for dynamically loaded modules) + sys.modules[spec.name] = mod + try: + spec.loader.exec_module(mod) + except Exception: + sys.modules.pop(spec.name, None) + raise + return mod + + +def claw_command(args): + """Route hermes claw subcommands.""" + action = getattr(args, "claw_action", None) + + if action == "migrate": + _cmd_migrate(args) + else: + print("Usage: hermes claw migrate [options]") + print() + print("Commands:") + print(" migrate Migrate settings from OpenClaw to Hermes") + print() + print("Run 'hermes claw migrate --help' for migration options.") + + +def _cmd_migrate(args): + """Run the OpenClaw → Hermes migration.""" + source_dir = Path(getattr(args, "source", None) or Path.home() / ".openclaw") + dry_run = getattr(args, "dry_run", False) + preset = getattr(args, "preset", "full") + overwrite = getattr(args, "overwrite", False) + migrate_secrets = getattr(args, "migrate_secrets", False) + workspace_target = getattr(args, "workspace_target", None) + skill_conflict = getattr(args, "skill_conflict", "skip") + + # If using the "full" preset, secrets are included by default + if preset == "full": + migrate_secrets = True + + print() + print( + color( + "┌─────────────────────────────────────────────────────────┐", + Colors.MAGENTA, + ) + ) + print( + color( + "│ ⚕ Hermes — OpenClaw Migration │", + Colors.MAGENTA, + ) + ) + print( + color( + "└─────────────────────────────────────────────────────────┘", + Colors.MAGENTA, + ) + ) + + # Check source directory + if not source_dir.is_dir(): + print() + print_error(f"OpenClaw directory not found: {source_dir}") + print_info("Make sure your OpenClaw installation is at the expected path.") + print_info(f"You can specify a custom path: hermes claw migrate --source /path/to/.openclaw") + return + + # Find the migration script + script_path = _find_migration_script() + if not script_path: + print() + print_error("Migration script not found.") + print_info("Expected at one of:") + print_info(f" {_OPENCLAW_SCRIPT}") + print_info(f" {_OPENCLAW_SCRIPT_INSTALLED}") + print_info("Make sure the openclaw-migration skill is installed.") + return + + # Show what we're doing + hermes_home = get_hermes_home() + print() + print_header("Migration Settings") + print_info(f"Source: {source_dir}") + print_info(f"Target: {hermes_home}") + print_info(f"Preset: {preset}") + print_info(f"Mode: {'dry run (preview only)' if dry_run else 'execute'}") + print_info(f"Overwrite: {'yes' if overwrite else 'no (skip conflicts)'}") + print_info(f"Secrets: {'yes (allowlisted only)' if migrate_secrets else 'no'}") + if skill_conflict != "skip": + print_info(f"Skill conflicts: {skill_conflict}") + if workspace_target: + print_info(f"Workspace: {workspace_target}") + print() + + # For execute mode (non-dry-run), confirm unless --yes was passed + if not dry_run and not getattr(args, "yes", False): + if not prompt_yes_no("Proceed with migration?", default=True): + print_info("Migration cancelled.") + return + + # Ensure config.yaml exists before migration tries to read it + config_path = get_config_path() + if not config_path.exists(): + save_config(load_config()) + + # Load and run the migration + try: + mod = _load_migration_module(script_path) + if mod is None: + print_error("Could not load migration script.") + return + + selected = mod.resolve_selected_options(None, None, preset=preset) + ws_target = Path(workspace_target).resolve() if workspace_target else None + + migrator = mod.Migrator( + source_root=source_dir.resolve(), + target_root=hermes_home.resolve(), + execute=not dry_run, + workspace_target=ws_target, + overwrite=overwrite, + migrate_secrets=migrate_secrets, + output_dir=None, + selected_options=selected, + preset_name=preset, + skill_conflict_mode=skill_conflict, + ) + report = migrator.migrate() + except Exception as e: + print() + print_error(f"Migration failed: {e}") + logger.debug("OpenClaw migration error", exc_info=True) + return + + # Print results + _print_migration_report(report, dry_run) + + +def _print_migration_report(report: dict, dry_run: bool): + """Print a formatted migration report.""" + summary = report.get("summary", {}) + migrated = summary.get("migrated", 0) + skipped = summary.get("skipped", 0) + conflicts = summary.get("conflict", 0) + errors = summary.get("error", 0) + total = migrated + skipped + conflicts + errors + + print() + if dry_run: + print_header("Dry Run Results") + print_info("No files were modified. This is a preview of what would happen.") + else: + print_header("Migration Results") + + print() + + # Detailed items + items = report.get("items", []) + if items: + # Group by status + migrated_items = [i for i in items if i.get("status") == "migrated"] + skipped_items = [i for i in items if i.get("status") == "skipped"] + conflict_items = [i for i in items if i.get("status") == "conflict"] + error_items = [i for i in items if i.get("status") == "error"] + + if migrated_items: + label = "Would migrate" if dry_run else "Migrated" + print(color(f" ✓ {label}:", Colors.GREEN)) + for item in migrated_items: + kind = item.get("kind", "unknown") + dest = item.get("destination", "") + if dest: + dest_short = str(dest).replace(str(Path.home()), "~") + print(f" {kind:<22s} → {dest_short}") + else: + print(f" {kind}") + print() + + if conflict_items: + print(color(f" ⚠ Conflicts (skipped — use --overwrite to force):", Colors.YELLOW)) + for item in conflict_items: + kind = item.get("kind", "unknown") + reason = item.get("reason", "already exists") + print(f" {kind:<22s} {reason}") + print() + + if skipped_items: + print(color(f" ─ Skipped:", Colors.DIM)) + for item in skipped_items: + kind = item.get("kind", "unknown") + reason = item.get("reason", "") + print(f" {kind:<22s} {reason}") + print() + + if error_items: + print(color(f" ✗ Errors:", Colors.RED)) + for item in error_items: + kind = item.get("kind", "unknown") + reason = item.get("reason", "unknown error") + print(f" {kind:<22s} {reason}") + print() + + # Summary line + parts = [] + if migrated: + action = "would migrate" if dry_run else "migrated" + parts.append(f"{migrated} {action}") + if conflicts: + parts.append(f"{conflicts} conflict(s)") + if skipped: + parts.append(f"{skipped} skipped") + if errors: + parts.append(f"{errors} error(s)") + + if parts: + print_info(f"Summary: {', '.join(parts)}") + else: + print_info("Nothing to migrate.") + + # Output directory + output_dir = report.get("output_dir") + if output_dir: + print_info(f"Full report saved to: {output_dir}") + + if dry_run: + print() + print_info("To execute the migration, run without --dry-run:") + print_info(f" hermes claw migrate --preset {report.get('preset', 'full')}") + elif migrated: + print() + print_success("Migration complete!") diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 8e25bc2d..64bc582f 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -22,6 +22,8 @@ Usage: hermes update # Update to latest version hermes uninstall # Uninstall Hermes Agent hermes sessions browse # Interactive session picker with search + hermes claw migrate # Migrate from OpenClaw to Hermes + hermes claw migrate --dry-run # Preview migration without changes """ import argparse @@ -2683,6 +2685,69 @@ For more help on a command: insights_parser.set_defaults(func=cmd_insights) + # ========================================================================= + # claw command (OpenClaw migration) + # ========================================================================= + claw_parser = subparsers.add_parser( + "claw", + help="OpenClaw migration tools", + description="Migrate settings, memories, skills, and API keys from OpenClaw to Hermes" + ) + claw_subparsers = claw_parser.add_subparsers(dest="claw_action") + + # claw migrate + claw_migrate = claw_subparsers.add_parser( + "migrate", + help="Migrate from OpenClaw to Hermes", + description="Import settings, memories, skills, and API keys from an OpenClaw installation" + ) + claw_migrate.add_argument( + "--source", + help="Path to OpenClaw directory (default: ~/.openclaw)" + ) + claw_migrate.add_argument( + "--dry-run", + action="store_true", + help="Preview what would be migrated without making changes" + ) + claw_migrate.add_argument( + "--preset", + choices=["user-data", "full"], + default="full", + help="Migration preset (default: full). 'user-data' excludes secrets" + ) + claw_migrate.add_argument( + "--overwrite", + action="store_true", + help="Overwrite existing files (default: skip conflicts)" + ) + claw_migrate.add_argument( + "--migrate-secrets", + action="store_true", + help="Include allowlisted secrets (TELEGRAM_BOT_TOKEN, API keys, etc.)" + ) + claw_migrate.add_argument( + "--workspace-target", + help="Absolute path to copy workspace instructions into" + ) + claw_migrate.add_argument( + "--skill-conflict", + choices=["skip", "overwrite", "rename"], + default="skip", + help="How to handle skill name conflicts (default: skip)" + ) + claw_migrate.add_argument( + "--yes", "-y", + action="store_true", + help="Skip confirmation prompts" + ) + + def cmd_claw(args): + from hermes_cli.claw import claw_command + claw_command(args) + + claw_parser.set_defaults(func=cmd_claw) + # ========================================================================= # version command # ========================================================================= diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 1d4df414..975dfd0c 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -2074,7 +2074,15 @@ def _offer_openclaw_migration(hermes_home: Path) -> bool: return False mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) + # Register in sys.modules so @dataclass can resolve the module + # (Python 3.11+ requires this for dynamically loaded modules) + import sys as _sys + _sys.modules[spec.name] = mod + try: + spec.loader.exec_module(mod) + except Exception: + _sys.modules.pop(spec.name, None) + raise # Run migration with the "full" preset, execute mode, no overwrite selected = mod.resolve_selected_options(None, None, preset="full") diff --git a/optional-skills/migration/openclaw-migration/SKILL.md b/optional-skills/migration/openclaw-migration/SKILL.md index d7ae9982..03bae5f6 100644 --- a/optional-skills/migration/openclaw-migration/SKILL.md +++ b/optional-skills/migration/openclaw-migration/SKILL.md @@ -14,6 +14,22 @@ metadata: Use this skill when a user wants to move their OpenClaw setup into Hermes Agent with minimal manual cleanup. +## CLI Command + +For a quick, non-interactive migration, use the built-in CLI command: + +```bash +hermes claw migrate # Full interactive migration +hermes claw migrate --dry-run # Preview what would be migrated +hermes claw migrate --preset user-data # Migrate without secrets +hermes claw migrate --overwrite # Overwrite existing conflicts +hermes claw migrate --source /custom/path/.openclaw # Custom source +``` + +The CLI command runs the same migration script described below. Use this skill (via the agent) when you want an interactive, guided migration with dry-run previews and per-item conflict resolution. + +**First-time setup:** The `hermes setup` wizard automatically detects `~/.openclaw` and offers migration before configuration begins. + ## What this skill does It uses `scripts/openclaw_to_hermes.py` to: diff --git a/tests/hermes_cli/test_claw.py b/tests/hermes_cli/test_claw.py new file mode 100644 index 00000000..a9788db9 --- /dev/null +++ b/tests/hermes_cli/test_claw.py @@ -0,0 +1,340 @@ +"""Tests for hermes claw commands.""" + +from argparse import Namespace +from types import ModuleType +from unittest.mock import MagicMock, patch + +import pytest + +from hermes_cli import claw as claw_mod + + +# --------------------------------------------------------------------------- +# _find_migration_script +# --------------------------------------------------------------------------- + + +class TestFindMigrationScript: + """Test script discovery in known locations.""" + + def test_finds_project_root_script(self, tmp_path): + script = tmp_path / "openclaw_to_hermes.py" + script.write_text("# placeholder") + with patch.object(claw_mod, "_OPENCLAW_SCRIPT", script): + assert claw_mod._find_migration_script() == script + + def test_finds_installed_script(self, tmp_path): + installed = tmp_path / "installed.py" + installed.write_text("# placeholder") + with ( + patch.object(claw_mod, "_OPENCLAW_SCRIPT", tmp_path / "nonexistent.py"), + patch.object(claw_mod, "_OPENCLAW_SCRIPT_INSTALLED", installed), + ): + assert claw_mod._find_migration_script() == installed + + def test_returns_none_when_missing(self, tmp_path): + with ( + patch.object(claw_mod, "_OPENCLAW_SCRIPT", tmp_path / "a.py"), + patch.object(claw_mod, "_OPENCLAW_SCRIPT_INSTALLED", tmp_path / "b.py"), + ): + assert claw_mod._find_migration_script() is None + + +# --------------------------------------------------------------------------- +# claw_command routing +# --------------------------------------------------------------------------- + + +class TestClawCommand: + """Test the claw_command router.""" + + def test_routes_to_migrate(self): + args = Namespace(claw_action="migrate", source=None, dry_run=True, + preset="full", overwrite=False, migrate_secrets=False, + workspace_target=None, skill_conflict="skip", yes=False) + with patch.object(claw_mod, "_cmd_migrate") as mock: + claw_mod.claw_command(args) + mock.assert_called_once_with(args) + + def test_shows_help_for_no_action(self, capsys): + args = Namespace(claw_action=None) + claw_mod.claw_command(args) + captured = capsys.readouterr() + assert "migrate" in captured.out + + +# --------------------------------------------------------------------------- +# _cmd_migrate +# --------------------------------------------------------------------------- + + +class TestCmdMigrate: + """Test the migrate command handler.""" + + def test_error_when_source_missing(self, tmp_path, capsys): + args = Namespace( + source=str(tmp_path / "nonexistent"), + dry_run=True, preset="full", overwrite=False, + migrate_secrets=False, workspace_target=None, + skill_conflict="skip", yes=False, + ) + claw_mod._cmd_migrate(args) + captured = capsys.readouterr() + assert "not found" in captured.out + + def test_error_when_script_missing(self, tmp_path, capsys): + openclaw_dir = tmp_path / ".openclaw" + openclaw_dir.mkdir() + args = Namespace( + source=str(openclaw_dir), + dry_run=True, preset="full", overwrite=False, + migrate_secrets=False, workspace_target=None, + skill_conflict="skip", yes=False, + ) + with ( + patch.object(claw_mod, "_OPENCLAW_SCRIPT", tmp_path / "a.py"), + patch.object(claw_mod, "_OPENCLAW_SCRIPT_INSTALLED", tmp_path / "b.py"), + ): + claw_mod._cmd_migrate(args) + captured = capsys.readouterr() + assert "Migration script not found" in captured.out + + def test_dry_run_succeeds(self, tmp_path, capsys): + openclaw_dir = tmp_path / ".openclaw" + openclaw_dir.mkdir() + script = tmp_path / "script.py" + script.write_text("# placeholder") + + # 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": 0, "skipped": 5, "conflict": 0, "error": 0}, + "items": [ + {"kind": "soul", "status": "skipped", "reason": "Not found"}, + ], + "preset": "full", + } + fake_mod.Migrator = MagicMock(return_value=fake_migrator) + + args = Namespace( + source=str(openclaw_dir), + dry_run=True, preset="full", overwrite=False, + migrate_secrets=False, workspace_target=None, + skill_conflict="skip", yes=False, + ) + + with ( + patch.object(claw_mod, "_find_migration_script", return_value=script), + patch.object(claw_mod, "_load_migration_module", return_value=fake_mod), + patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"), + patch.object(claw_mod, "save_config"), + patch.object(claw_mod, "load_config", return_value={}), + ): + claw_mod._cmd_migrate(args) + + captured = capsys.readouterr() + assert "Dry Run Results" in captured.out + assert "5 skipped" in captured.out + + def test_execute_with_confirmation(self, tmp_path, capsys): + openclaw_dir = tmp_path / ".openclaw" + openclaw_dir.mkdir() + config_path = tmp_path / "config.yaml" + config_path.write_text("agent:\n max_turns: 90\n") + + fake_mod = ModuleType("openclaw_to_hermes") + fake_mod.resolve_selected_options = MagicMock(return_value={"soul"}) + fake_migrator = MagicMock() + fake_migrator.migrate.return_value = { + "summary": {"migrated": 2, "skipped": 1, "conflict": 0, "error": 0}, + "items": [ + {"kind": "soul", "status": "migrated", "destination": str(tmp_path / "SOUL.md")}, + {"kind": "memory", "status": "migrated", "destination": str(tmp_path / "memories/MEMORY.md")}, + ], + } + fake_mod.Migrator = MagicMock(return_value=fake_migrator) + + args = Namespace( + source=str(openclaw_dir), + dry_run=False, preset="user-data", overwrite=False, + migrate_secrets=False, workspace_target=None, + skill_conflict="skip", yes=False, + ) + + with ( + patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"), + patch.object(claw_mod, "_load_migration_module", return_value=fake_mod), + patch.object(claw_mod, "get_config_path", return_value=config_path), + patch.object(claw_mod, "prompt_yes_no", return_value=True), + ): + claw_mod._cmd_migrate(args) + + captured = capsys.readouterr() + assert "Migration Results" in captured.out + assert "Migration complete!" in captured.out + + def test_execute_cancelled_by_user(self, tmp_path, capsys): + openclaw_dir = tmp_path / ".openclaw" + openclaw_dir.mkdir() + config_path = tmp_path / "config.yaml" + config_path.write_text("") + + args = Namespace( + source=str(openclaw_dir), + dry_run=False, preset="full", overwrite=False, + migrate_secrets=False, workspace_target=None, + skill_conflict="skip", yes=False, + ) + + with ( + patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"), + patch.object(claw_mod, "prompt_yes_no", return_value=False), + ): + claw_mod._cmd_migrate(args) + + captured = capsys.readouterr() + assert "Migration cancelled" in captured.out + + def test_execute_with_yes_skips_confirmation(self, tmp_path, capsys): + openclaw_dir = tmp_path / ".openclaw" + openclaw_dir.mkdir() + config_path = tmp_path / "config.yaml" + config_path.write_text("") + + fake_mod = ModuleType("openclaw_to_hermes") + fake_mod.resolve_selected_options = MagicMock(return_value=set()) + fake_migrator = MagicMock() + fake_migrator.migrate.return_value = { + "summary": {"migrated": 0, "skipped": 0, "conflict": 0, "error": 0}, + "items": [], + } + fake_mod.Migrator = MagicMock(return_value=fake_migrator) + + args = Namespace( + source=str(openclaw_dir), + dry_run=False, preset="full", overwrite=False, + migrate_secrets=False, workspace_target=None, + skill_conflict="skip", yes=True, + ) + + with ( + patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"), + patch.object(claw_mod, "_load_migration_module", return_value=fake_mod), + patch.object(claw_mod, "get_config_path", return_value=config_path), + patch.object(claw_mod, "prompt_yes_no") as mock_prompt, + ): + claw_mod._cmd_migrate(args) + + mock_prompt.assert_not_called() + + def test_handles_migration_error(self, tmp_path, capsys): + openclaw_dir = tmp_path / ".openclaw" + openclaw_dir.mkdir() + config_path = tmp_path / "config.yaml" + config_path.write_text("") + + args = Namespace( + source=str(openclaw_dir), + dry_run=True, preset="full", overwrite=False, + migrate_secrets=False, workspace_target=None, + skill_conflict="skip", yes=False, + ) + + with ( + patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"), + patch.object(claw_mod, "_load_migration_module", side_effect=RuntimeError("boom")), + patch.object(claw_mod, "get_config_path", return_value=config_path), + patch.object(claw_mod, "save_config"), + patch.object(claw_mod, "load_config", return_value={}), + ): + claw_mod._cmd_migrate(args) + + captured = capsys.readouterr() + assert "Migration failed" in captured.out + + def test_full_preset_enables_secrets(self, tmp_path, capsys): + """The 'full' preset should set migrate_secrets=True automatically.""" + openclaw_dir = tmp_path / ".openclaw" + openclaw_dir.mkdir() + + fake_mod = ModuleType("openclaw_to_hermes") + fake_mod.resolve_selected_options = MagicMock(return_value=set()) + fake_migrator = MagicMock() + fake_migrator.migrate.return_value = { + "summary": {"migrated": 0, "skipped": 0, "conflict": 0, "error": 0}, + "items": [], + } + fake_mod.Migrator = MagicMock(return_value=fake_migrator) + + args = Namespace( + source=str(openclaw_dir), + dry_run=True, preset="full", overwrite=False, + migrate_secrets=False, # Not explicitly set by user + workspace_target=None, + skill_conflict="skip", yes=False, + ) + + with ( + patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"), + patch.object(claw_mod, "_load_migration_module", return_value=fake_mod), + patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"), + patch.object(claw_mod, "save_config"), + patch.object(claw_mod, "load_config", return_value={}), + ): + claw_mod._cmd_migrate(args) + + # Migrator should have been called with migrate_secrets=True + call_kwargs = fake_mod.Migrator.call_args[1] + assert call_kwargs["migrate_secrets"] is True + + +# --------------------------------------------------------------------------- +# _print_migration_report +# --------------------------------------------------------------------------- + + +class TestPrintMigrationReport: + """Test the report formatting function.""" + + def test_dry_run_report(self, capsys): + report = { + "summary": {"migrated": 2, "skipped": 1, "conflict": 1, "error": 0}, + "items": [ + {"kind": "soul", "status": "migrated", "destination": "/home/user/.hermes/SOUL.md"}, + {"kind": "memory", "status": "migrated", "destination": "/home/user/.hermes/memories/MEMORY.md"}, + {"kind": "skills", "status": "conflict", "reason": "already exists"}, + {"kind": "tts-assets", "status": "skipped", "reason": "not found"}, + ], + "preset": "full", + } + claw_mod._print_migration_report(report, dry_run=True) + captured = capsys.readouterr() + assert "Dry Run Results" in captured.out + assert "Would migrate" in captured.out + assert "2 would migrate" in captured.out + assert "--dry-run" in captured.out + + def test_execute_report(self, capsys): + report = { + "summary": {"migrated": 3, "skipped": 0, "conflict": 0, "error": 0}, + "items": [ + {"kind": "soul", "status": "migrated", "destination": "/home/user/.hermes/SOUL.md"}, + ], + "output_dir": "/home/user/.hermes/migration/openclaw/20250312T120000", + } + claw_mod._print_migration_report(report, dry_run=False) + captured = capsys.readouterr() + assert "Migration Results" in captured.out + assert "Migrated" in captured.out + assert "Full report saved to" in captured.out + + def test_empty_report(self, capsys): + report = { + "summary": {"migrated": 0, "skipped": 0, "conflict": 0, "error": 0}, + "items": [], + } + claw_mod._print_migration_report(report, dry_run=False) + captured = capsys.readouterr() + assert "Nothing to migrate" in captured.out