358 lines
15 KiB
Python
358 lines
15 KiB
Python
"""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
|