"""Post-migration section-skip logic and the OpenClaw first-run migration flow. Extracted from hermes_cli/setup.py, which re-exports the names it still uses. """ import importlib.util import logging import sys from functools import partial from pathlib import Path from typing import Optional from hermes_constants import get_optional_skills_dir logger = logging.getLogger("hermes_cli.setup") PROJECT_ROOT = Path(__file__).parent.parent.resolve() # ── Post-Migration Section Skip Logic ── _OPENROUTER_ENV_VARS = ("OPENROUTER_API_KEY", "OPENAI_API_KEY") def _model_section_has_credentials(config: dict) -> bool: """Return True when any known inference provider has usable credentials. Sources of truth: ``PROVIDER_REGISTRY`` in ``hermes_cli.auth`` (every provider with its ``api_key_env_vars``); ``active_provider`` in the auth store (OAuth device-code / external-OAuth providers: Nous, Codex, Qwen, Gemini CLI, ...); and the legacy OpenRouter aggregator env vars, which route generic ``OPENAI_API_KEY`` / ``OPENROUTER_API_KEY`` values through OpenRouter. """ from hermes_cli.setup import get_env_value try: from hermes_cli.auth import get_active_provider if get_active_provider(): return True except Exception: pass try: from hermes_cli.auth import PROVIDER_REGISTRY except Exception: PROVIDER_REGISTRY = {} # type: ignore[assignment] def _has_key(pconfig) -> bool: # CLAUDE_CODE_OAUTH_TOKEN is set by Claude Code itself, not by the user — # mirrors is_provider_explicitly_configured in auth.py. return any(get_env_value(v) for v in pconfig.api_key_env_vars if v != "CLAUDE_CODE_OAUTH_TOKEN") def _any_openrouter_key() -> bool: return any(get_env_value(v) for v in _OPENROUTER_ENV_VARS) # Prefer the provider declared in config.yaml, avoids false positives from stray # env vars (GH_TOKEN, etc.) when the user has already picked a different provider. model_cfg = config.get("model") if isinstance(config, dict) else None if isinstance(model_cfg, dict): provider_id = (model_cfg.get("provider") or "").strip().lower() if provider_id in PROVIDER_REGISTRY and _has_key(PROVIDER_REGISTRY[provider_id]): return True if provider_id == "openrouter" and _any_openrouter_key(): return True # OpenRouter aggregator fallback (no provider declared in config). if _any_openrouter_key(): return True # Skip copilot in auto-detect: GH_TOKEN / GITHUB_TOKEN are commonly set for # git tooling. Mirrors resolve_provider in auth.py. return any(_has_key(pconfig) for pid, pconfig in PROVIDER_REGISTRY.items() if pid != "copilot") def _gateway_platform_short_label(label: str) -> str: """Strip trailing parenthetical qualifiers from a gateway platform label.""" return label.split("(", 1)[0].strip() or label def _model_summary(config: dict) -> Optional[str]: if not _model_section_has_credentials(config): return None model = config.get("model") if isinstance(model, str) and model.strip(): return model.strip() if isinstance(model, dict): return str(model.get("default") or model.get("model") or "configured") return "configured" def _cfg_summary(config: dict, section: str, key: str, default, prefix: str) -> str: from hermes_cli.setup import cfg_get return f"{prefix}{cfg_get(config, section, key, default=default)}" def _gateway_summary(config: dict) -> Optional[str]: from hermes_cli.gateway import _all_platforms, _platform_status # Count any non-empty status other than the "not configured" sentinel — platforms like # WhatsApp ("enabled, not paired"), Matrix ("configured + E2EE"), and Signal ("partially # configured") all indicate the user has already started setup and we shouldn't force # the section to rerun. No platforms configured -> None -> section must run. configured = [ _gateway_platform_short_label(plat["label"]) for plat in _all_platforms() if _platform_status(plat) and _platform_status(plat) != "not configured" ] return ", ".join(configured) if configured else None _TOOL_ENV_LABELS = ( ("ELEVENLABS_API_KEY", "TTS/ElevenLabs"), ("BROWSERBASE_API_KEY", "Browser"), ("FIRECRAWL_API_KEY", "Firecrawl"), ) def _tools_summary(config: dict) -> Optional[str]: from hermes_cli.setup import get_env_value tools = [label for env_var, label in _TOOL_ENV_LABELS if get_env_value(env_var)] return ", ".join(tools) if tools else None _SECTION_SUMMARIES = { "model": _model_summary, "terminal": partial(_cfg_summary, section="terminal", key="backend", default="local", prefix="backend: "), "agent": partial(_cfg_summary, section="agent", key="max_turns", default=90, prefix="max turns: "), "gateway": _gateway_summary, "tools": _tools_summary, } def _get_section_config_summary(config: dict, section_key: str) -> Optional[str]: """Return a short summary if a setup section is already configured, else None. Used after OpenClaw migration to detect which sections can be skipped. ``get_env_value`` is reached through hermes_cli.setup so that test patches on ``setup_mod.get_env_value`` apply. """ summarize = _SECTION_SUMMARIES.get(section_key) return summarize(config) if summarize else None def _skip_configured_section(config: dict, section_key: str, label: str) -> bool: """Show an already-configured section summary and offer to skip. Returns True if the user chose to skip, False if the section should run. """ from hermes_cli.setup import print_success, prompt_yes_no summary = _get_section_config_summary(config, section_key) if not summary: return False print() print_success(f" {label}: {summary}") return not prompt_yes_no(f" Reconfigure {label.lower()}?", default=False) # ── OpenClaw Migration ── _OPENCLAW_SCRIPT = ( get_optional_skills_dir(PROJECT_ROOT / "optional-skills") / "migration" / "openclaw-migration" / "scripts" / "openclaw_to_hermes.py" ) def _load_openclaw_migration_module(): """Load the openclaw_to_hermes migration script as a module; None if it can't be loaded.""" from hermes_cli.setup import _OPENCLAW_SCRIPT if not _OPENCLAW_SCRIPT.exists(): return None spec = importlib.util.spec_from_file_location("openclaw_to_hermes", _OPENCLAW_SCRIPT) 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 # Item kinds that represent high-impact changes warranting explicit warnings. # Gateway tokens/channels can hijack messaging platforms from the old agent. # Config values may have different semantics between OpenClaw and Hermes. # Instruction/context files (.md) can contain incompatible setup procedures. _HIGH_IMPACT_KIND_KEYWORDS = { "gateway": "⚠ Gateway/messaging — this will configure Hermes to use your OpenClaw messaging channels", "telegram": "⚠ Telegram — this will point Hermes at your OpenClaw Telegram bot", "slack": "⚠ Slack — this will point Hermes at your OpenClaw Slack workspace", "discord": "⚠ Discord — this will point Hermes at your OpenClaw Discord bot", "whatsapp": "⚠ WhatsApp — this will point Hermes at your OpenClaw WhatsApp connection", "config": "⚠ Config values — OpenClaw settings may not map 1:1 to Hermes equivalents", "soul": "⚠ Instruction file — may contain OpenClaw-specific setup/restart procedures", "memory": "⚠ Memory/context file — may reference OpenClaw-specific infrastructure", "context": "⚠ Context file — may contain OpenClaw-specific instructions", } _MIGRATION_WARNING_NOTES = ( " Note: OpenClaw config values may have different semantics in Hermes.", " For example, OpenClaw's tool_call_execution: \"auto\" ≠ Hermes's yolo mode.", " Instruction files (.md) from OpenClaw may contain incompatible procedures.", ) def _migrated_row(item: dict, kind: str) -> str: dest = item.get("destination", "") if dest: return f" {kind:<22s} → {str(dest).replace(str(Path.home()), '~')}" return f" {kind}" def _reason_row(default_reason: str, item: dict, kind: str) -> str: return f" {kind:<22s} {item.get('reason', default_reason)}" def _print_migration_preview(report: dict): """Print a detailed dry-run preview of what migration would do. Groups items by status and adds explicit warnings for high-impact changes like gateway token takeover and config value differences. """ from hermes_cli.setup import Colors, color, print_info items = report.get("items", []) if not items: print_info("Nothing to migrate.") return groups = ( ("migrated", " Would import:", Colors.GREEN, _migrated_row), ("conflict", " Would overwrite (conflicts with existing Hermes config):", Colors.YELLOW, partial(_reason_row, "already exists")), ("skipped", " Would skip:", Colors.DIM, partial(_reason_row, "")), ) warnings_shown = set() for status, header, col, row in groups: group = [i for i in items if i.get("status") == status] if not group: continue print(color(header, col)) for item in group: kind = item.get("kind", "unknown") print(row(item, kind)) if status == "migrated": # collect warnings for high-impact items kind_lower, dest_lower = kind.lower(), str(item.get("destination", "")).lower() warnings_shown.update( w for kw, w in _HIGH_IMPACT_KIND_KEYWORDS.items() if kw in kind_lower or kw in dest_lower ) print() if warnings_shown: print(color(" ── Warnings ──", Colors.YELLOW)) for warning in sorted(warnings_shown): print(color(f" {warning}", Colors.YELLOW)) print() for line in _MIGRATION_WARNING_NOTES: print(color(line, Colors.YELLOW)) print() def _run_migrator(mod, openclaw_dir: Path, hermes_home: Path, selected, *, execute: bool, overwrite: bool): """Run a Migrator with the fixed first-time-setup options and return its report.""" return mod.Migrator( source_root=openclaw_dir.resolve(), target_root=hermes_home.resolve(), execute=execute, workspace_target=None, overwrite=overwrite, migrate_secrets=True, output_dir=None, selected_options=selected, preset_name="full", ).migrate() def _offer_openclaw_migration(hermes_home: Path) -> bool: """Detect ~/.openclaw and offer to migrate during first-time setup. Runs a dry-run first to show the user exactly what would be imported, overwritten, or taken over. Only executes after explicit confirmation. Returns True if migration ran successfully, False otherwise. """ from hermes_cli.setup import ( _OPENCLAW_SCRIPT, get_config_path, _info, load_config, print_header, print_info, print_success, print_warning, prompt_yes_no, save_config, ) openclaw_dir = Path.home() / ".openclaw" if not openclaw_dir.is_dir() or not _OPENCLAW_SCRIPT.exists(): return False print() print_header("OpenClaw Installation Detected") _info(f"Found OpenClaw data at {openclaw_dir}", "Hermes can preview what would be imported before making any changes.", None) if not prompt_yes_no("Would you like to see what can be imported?", default=True): print_info("Skipping migration. You can run it later with: hermes claw migrate --dry-run") return False # Ensure config.yaml exists before migration tries to read it if not get_config_path().exists(): save_config(load_config()) try: mod = _load_openclaw_migration_module() if mod is None: print_warning("Could not load migration script.") return False except Exception as e: print_warning(f"Could not load migration script: {e}") logger.debug("OpenClaw migration module load error", exc_info=True) return False # ── Phase 1: Dry-run preview (overwrite=True shows everything, including conflicts) ── try: selected = mod.resolve_selected_options(None, None, preset="full") preview_report = _run_migrator(mod, openclaw_dir, hermes_home, selected, execute=False, overwrite=True) except Exception as e: print_warning(f"Migration preview failed: {e}") logger.debug("OpenClaw migration preview error", exc_info=True) return False preview_count = preview_report.get("summary", {}).get("migrated", 0) if preview_count == 0: _info(None, "Nothing to import from OpenClaw.") return False print() print_header(f"Migration Preview — {preview_count} item(s) would be imported") _info("No changes have been made yet. Review the list below:", None) _print_migration_preview(preview_report) # ── Phase 2: Confirm and execute ── if not prompt_yes_no("Proceed with migration?", default=False): _info("Migration cancelled. You can run it later with: hermes claw migrate", "Use --dry-run to preview again, or --preset minimal for a lighter import.") return False # overwrite=False so existing Hermes configs are preserved. The user saw the # preview; conflicts are skipped by default. try: report = _run_migrator(mod, openclaw_dir, hermes_home, selected, execute=True, overwrite=False) except Exception as e: print_warning(f"Migration failed: {e}") logger.debug("OpenClaw migration error", exc_info=True) return False summary = report.get("summary", {}) migrated, skipped = summary.get("migrated", 0), summary.get("skipped", 0) conflicts, errors = summary.get("conflict", 0), summary.get("error", 0) print() for count, printer, text in ( (migrated, print_success, f"Imported {migrated} item(s) from OpenClaw."), (conflicts, print_info, f"Skipped {conflicts} item(s) that already exist in Hermes (use hermes claw migrate --overwrite to force)."), (skipped, print_info, f"Skipped {skipped} item(s) (not found or unchanged)."), (errors, print_warning, f"{errors} item(s) had errors — check the migration report."), ): if count: printer(text) 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