mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
211 lines
12 KiB
Python
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
|