"""First-time ``hermes setup``'s "Coming from another agent?" step (it replaces core's old OpenClaw migration offer). Detection is a few ``is_dir`` checks and needs nothing installed. Importing lives in the ``agent-importers`` catalog plugin (``hermes import-agent ``): only after the user says yes does this step install it through the normal catalog install and run its import in a child process, which owns the preview, the confirmation and what gets imported. Setup helpers are imported lazily from hermes_cli.setup so test patches there apply. """ from __future__ import annotations import os import subprocess import sys import textwrap from pathlib import Path from typing import NamedTuple, Optional PLUGIN = "agent-importers" class ImportSource(NamedTuple): name: str # ``hermes import-agent `` label: str dirs: tuple[str, ...] # paths under ``~`` (or absolute) that show the agent is here; first found wins extra_args: tuple[str, ...] = () exports: tuple[str, ...] = () # globs under ``~`` for export files the plugin reads # The plugin's importers, with the dirs its own auto-detection checks. IMPORT_SOURCES: tuple[ImportSource, ...] = ( # Core's setup step migrated OpenClaw's allowlisted API keys and bot tokens after the preview, so # the provider and messaging sections that follow find them already set; kept. ImportSource("openclaw", "OpenClaw", (".openclaw", ".clawdbot", ".moltbot"), ("--migrate-secrets",)), ImportSource("claude-code", "Claude Code", (".claude",)), ImportSource("codex", "Codex CLI", (".codex",)), ImportSource("cursor", "Cursor", (".cursor",)), # Grok Bot lives in its cloud account: the desktop app's dirs only show it is used here, and the # plugin walks the user through having each Bot export itself (or reads an export already saved). ImportSource("grok-bot", "Grok Bot", ("Library/Application Support/Grok Bot", "/Applications/Grok Bot.app", "Applications/Grok Bot.app", ".config/Grok Bot", "AppData/Roaming/Grok Bot", "AppData/Local/Programs/Grok Bot"), exports=("Downloads/grok-bot-export*",)), # OpenBot (nightly-labs/openbot): Electron user data with openbot.db, per OS, or its agent workspaces. ImportSource("openbot", "OpenBot", ("Library/Application Support/OpenBot", ".config/OpenBot", "AppData/Roaming/OpenBot", "OpenBot/Agents")), ) def detect_import_sources() -> list[tuple[ImportSource, Path]]: """The sources with a setup on this computer, each with the dir found.""" found = [] for source in IMPORT_SOURCES: home = next((p for p in (Path.home() / d for d in source.dirs) if p.is_dir()), None) home = home or next((p for g in source.exports for p in sorted(Path.home().glob(g))), None) if home is not None: found.append((source, home)) return found def offer_agent_import(hermes_home: Path, from_setup: bool = True) -> str: """When another agent's setup is on this computer, offer to import it: pick which (when several), install the plugin if it is missing and run its import for each pick. Returns the labels whose import changed this home's config.yaml or .env (``""`` when none did). Asks nothing, and so never installs anything, when nothing is found or no one can answer the prompts.""" from hermes_cli.setup import _info, is_noninteractive, print_header, print_info, prompt_yes_no found = [] if is_noninteractive() else detect_import_sources() if not found: return "" user_home = str(Path.home()) print_header("Coming from another agent?", gap=True) context = ("Found on this computer: " + ", ".join( f"{s.label} ({str(p).replace(user_home, '~', 1)})" for s, p in found) + ".", "Hermes can bring over its settings, memories, skills and MCP servers.", "You'll see a preview and confirm before anything changes.") _info(*context) names = " or ".join(s.label for s, _ in found) if len(found) <= 2 else "any of them" # The menu clears the screen, so the question carries what it refers to ("any of them"). if not prompt_yes_no(f"Import your setup from {names}?", default=True, description="\n".join(f" {w}" for line in context for w in textwrap.wrap(line, 76))): print_info(f"Skipped. Import later with: hermes plugins install {PLUGIN}, then hermes import-agent") return "" picked = _pick_sources(found) if not picked or not _ensure_plugin(hermes_home): return "" return ", ".join(s.label for s in picked if _run_import(s, hermes_home, from_setup)) def _pick_sources(found: list[tuple[ImportSource, Path]]) -> list[ImportSource]: """Only one found: that one (the question named it). Several: a checklist, all checked.""" from hermes_cli.setup import print_info, prompt_checklist if len(found) == 1: return [found[0][0]] picked = prompt_checklist("Which agents should Hermes import from?", [s.label for s, _ in found], list(range(len(found)))) if not picked: print_info("Nothing selected, so nothing was imported.") return [found[i][0] for i in picked] def _ensure_plugin(hermes_home: Path) -> bool: """The plugin installed and enabled in *hermes_home*: the catalog install (reviewed pin, the usual install checks) when it is missing, else just an enable.""" from hermes_cli.left_core_migration import plugin_present from hermes_cli.plugins_admission import AdmissionRefused from hermes_cli.plugins_cmd import cmd_enable from hermes_cli.plugins_cmd_install import cmd_install from hermes_cli.setup import _info, print_warning present = plugin_present(PLUGIN, hermes_home) if not present: _info(None, f"Importing is done by the '{PLUGIN}' plugin from the Hermes plugin catalog. " "Installing it now...") try: if present: cmd_enable(PLUGIN) else: cmd_install(PLUGIN, enable=True, brief=True) # the line above already said what and why # Both print their own error first: a refusal exits, an enable whose dependencies PM cannot # admit raises. Either way the import is skipped and the rest of setup goes on. except (SystemExit, AdmissionRefused): pass else: if plugin_present(PLUGIN, hermes_home): return True verb = "enabled" if present else "installed" print_warning(f"The '{PLUGIN}' plugin could not be {verb}, so nothing was imported. " f"Try again later with: hermes plugins {'enable' if present else 'install'} {PLUGIN}") return False def _snapshot(hermes_home: Path) -> tuple[Optional[bytes], ...]: return tuple(p.read_bytes() if p.is_file() else None # config-reader: ok — child-run change snapshot for p in (hermes_home / "config.yaml", hermes_home / ".env")) def _run_import(source: ImportSource, hermes_home: Path, from_setup: bool = True) -> bool: """The offer's import: :func:`_import_child` for *source*; True when the run changed config.yaml or .env, which the setup sections after it read. Bare ``hermes migrate`` runs it as its own command (``from_setup`` False: no setup-only flags such as OpenClaw's ``--migrate-secrets``).""" before = _snapshot(hermes_home) _import_child(source, hermes_home, from_setup=from_setup) return _snapshot(hermes_home) != before def _import_child(source: ImportSource, hermes_home: Path, extra_args: tuple[str, ...] = (), from_setup: bool = True) -> int: """``hermes import-agent `` in a child process, so the freshly installed plugin's command is wired; it previews and asks before changing anything (and exits 0 on a no too). Returns its exit code. ``hermes migrate`` passes the user's flags as ``extra_args`` and runs it as a command of its own (``from_setup`` False).""" from hermes_cli.setup import print_header, print_warning from tools.environments.local import served_profile_child_env print_header(f"Importing from {source.label}", gap=True) # A home that is not /profiles/ is the root: without a selector the child's # _apply_profile_override would follow the sticky active_profile into another profile. selector = [] if hermes_home.resolve().parent.name == "profiles" else ["--profile", "default"] # --from-setup: the plugin's import speaks as a step of this setup (no "run hermes setup" pointers, # OpenClaw's skipped items folded into one line). # source.extra_args are setup's own choices (OpenClaw's --migrate-secrets, what core's old setup # step did); `hermes migrate` passes only what the user typed. argv = [sys.executable, "-P", "-m", "hermes_cli.main", *selector, "import-agent", source.name, *(source.extra_args if from_setup else ()), *extra_args, *(["--from-setup"] if from_setup else [])] env = served_profile_child_env(target_home=hermes_home) if os.environ.get("PYTHONPATH"): # this same interpreter running Hermes: keep the source tree env["PYTHONPATH"] = os.environ["PYTHONPATH"] # a checkout launched by path, which it strips # health: allow HX006 -- interactive child: the user reads its preview and answers its prompts returncode = subprocess.run(argv, env=env, check=False).returncode # An install from before the plugin gained this importer (or --from-setup) refuses it in argparse # (exit 2) before asking anything: update the plugin once and try again. if returncode == 2 and _update_plugin(source): returncode = subprocess.run(argv, env=env, check=False).returncode # health: allow HX006 -- same child if returncode == 2: print_warning(f"The {source.label} import did not run. If the error names an invalid choice or " f"unrecognized arguments, update the plugin first: hermes plugins update {PLUGIN}") elif returncode: print_warning(f"The {source.label} import stopped with errors; see the report above.") return returncode def _update_plugin(source: ImportSource) -> bool: """Bring the installed plugin to the catalog's current pin; False when that update failed.""" from hermes_cli.plugins_cmd_update import cmd_update from hermes_cli.setup import _info _info(None, f"Your '{PLUGIN}' plugin predates the {source.label} importer. Updating it...") try: cmd_update(PLUGIN, interactive=False) except SystemExit: # it printed why return False return True def migrate_from_agents(hermes_home: Path, source_name: str = "", extra_args: tuple[str, ...] = ()) -> int: """``hermes migrate []``: the same import as setup's step, on demand. Named: install the plugin when missing and run that agent's import with ``extra_args``. Bare: offer the agents found on this computer, as setup does.""" from hermes_cli.setup import is_noninteractive, print_info if not source_name: found = detect_import_sources() if found and not is_noninteractive(): offer_agent_import(hermes_home, from_setup=False) return 0 print_info(("Found: " + ", ".join(s.label for s, _ in found) + ". " if found else "No other agent setup found on this computer. ") + "Import one with: " + ", ".join(f"hermes migrate {s.name}" for s in IMPORT_SOURCES)) return 0 source = next(s for s in IMPORT_SOURCES if s.name == source_name) if not _ensure_plugin(hermes_home): return 1 return 1 if _import_child(source, hermes_home, extra_args, from_setup=False) else 0