- Add hermes_cli/claw.py with full CLI migration handler: - hermes claw migrate (interactive migration with confirmation) - --dry-run, --preset, --overwrite, --skill-conflict flags - --source for custom OpenClaw path - --yes to skip confirmation - Clean formatted output matching setup wizard style - Fix Python 3.11+ @dataclass compatibility bug in dynamic module loading: - Register module in sys.modules before exec_module() - Fixes both setup.py (PR #981) and new claw.py - Add 16 tests in tests/hermes_cli/test_claw.py covering: - Script discovery (project root, installed, missing) - Command routing - Dry-run, execute, cancellation, error handling - Preset/secrets behavior, report formatting - Documentation updates: - README.md: Add 'hermes claw migrate' to Getting Started, new Migration section - docs/migration/openclaw.md: Full migration guide with all options - SKILL.md: Add CLI Command section at top of openclaw-migration skill
297 lines
9.8 KiB
Python
297 lines
9.8 KiB
Python
"""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!")
|