Files

211 lines
12 KiB
Python

"""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 <source>``): 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 <name>``
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 <source>`` 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 <root>/profiles/<name> 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 [<agent>]``: 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