hermes-agent/hermes_cli/agent_import.py
teknium1 24c3c27ba8 feat(cli): hermes import-agent — import Claude Code and Codex CLI setups
Maps CLAUDE.md/AGENTS.md, permission allowlists, MCP servers, skills, and memories into their Hermes equivalents. Follows the openclaw migration pattern. Inspired by ChatGPT Work import-from-another-agent onboarding.
2026-07-26 17:47:07 -07:00

905 lines
35 KiB
Python

"""hermes import-agent — import Claude Code / Codex CLI setups into Hermes.
Usage:
hermes import-agent # auto-detect ~/.claude or ~/.codex
hermes import-agent claude-code # import from ~/.claude
hermes import-agent codex # import from ~/.codex
hermes import-agent claude-code --dry-run # preview only, no changes
hermes import-agent codex --source /path/to/.codex
Follows the OpenClaw migration pattern (``hermes claw migrate`` /
``optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py``):
detect → parse → map → apply, with a mandatory preview phase, per-item
imported/skipped/conflict/error records, and a ``--dry-run`` that writes
nothing. The memory-entry merge and allowlist-merge primitives here are
self-contained ports of the openclaw script's equivalents so this command
works even when the optional migration skill is not installed.
Mappings
--------
claude-code (~/.claude):
CLAUDE.md → memory entries in HERMES_HOME/memories/MEMORY.md
settings.json permissions.allow → config.yaml command_allowlist (Bash(...) rules)
settings.json permissions.deny → config.yaml approvals.deny (Bash(...) rules)
mcpServers (~/.claude.json or settings.json) → config.yaml mcp_servers
skills/<name>/SKILL.md → HERMES_HOME/skills/claude-code-imports/<name>/
codex (~/.codex):
AGENTS.md → memory entries in HERMES_HOME/memories/MEMORY.md
config.toml [mcp_servers.*] → config.yaml mcp_servers
memories/*.md → memory entries in HERMES_HOME/memories/MEMORY.md
skills/<name>/SKILL.md → HERMES_HOME/skills/codex-imports/<name>/
Secrets are NEVER imported: credential files (.credentials.json, auth.json)
are ignored, and MCP server env vars with secret-looking names (KEY, TOKEN,
SECRET, PASSWORD, ...) are stripped and reported so the user can re-add them
deliberately via ``hermes setup`` or config.yaml.
"""
from __future__ import annotations
import json
import logging
import re
import shutil
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple
logger = logging.getLogger(__name__)
# Same entry delimiter as the Hermes memory store and the openclaw migration
# script — memories/MEMORY.md entries are separated by bare "§" lines.
ENTRY_DELIMITER = "\n§\n"
# Character budget for merged memory files (matches the openclaw script's
# default memory limit).
MEMORY_CHAR_LIMIT = 20_000
SUPPORTED_AGENTS = ("claude-code", "codex")
_AGENT_DEFAULT_DIRS = {
"claude-code": ".claude",
"codex": ".codex",
}
_SKILL_CATEGORY = {
"claude-code": "claude-code-imports",
"codex": "codex-imports",
}
# Env var names that look like credentials — never copied into config.yaml.
_SECRET_KEY_RE = re.compile(
r"(?:^|_)(?:API[_-]?KEY|APIKEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|"
r"AUTH|PRIVATE[_-]?KEY|ACCESS[_-]?KEY)(?:_|$)|KEY$",
re.IGNORECASE,
)
# Files inside the source tree that hold credentials — never read.
_CREDENTIAL_FILENAMES = (".credentials.json", "auth.json", "credentials.json")
def is_secret_key(key: str) -> bool:
"""Return True when an env-var name looks like a credential."""
return bool(_SECRET_KEY_RE.search(key or ""))
def normalize_text(text: str) -> str:
return re.sub(r"\s+", " ", (text or "").strip()).lower()
def read_text(path: Path) -> str:
return path.read_text(encoding="utf-8", errors="replace")
def load_yaml_file(path: Path) -> Dict[str, Any]:
import yaml
if not path.exists():
return {}
try:
data = yaml.safe_load(read_text(path))
except Exception:
return {}
return data if isinstance(data, dict) else {}
def dump_yaml_file(path: Path, data: Dict[str, Any]) -> None:
import yaml
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
yaml.safe_dump(data, default_flow_style=False, sort_keys=False,
allow_unicode=True),
encoding="utf-8",
)
# ---------------------------------------------------------------------------
# Memory-entry primitives (ported from openclaw_to_hermes.py)
# ---------------------------------------------------------------------------
def extract_markdown_entries(text: str) -> List[str]:
"""Split a markdown document into individual memory entries.
Headings become context prefixes, bullets and paragraphs become entries.
Code blocks and tables are skipped. Port of the openclaw migration
script's extractor.
"""
entries: List[str] = []
headings: List[str] = []
paragraph_lines: List[str] = []
def context_prefix() -> str:
filtered = [
h for h in headings
if h and not re.search(
r"\b(MEMORY|USER|SOUL|AGENTS|TOOLS|IDENTITY|CLAUDE)\.md\b",
h, re.I,
)
]
return " > ".join(filtered)
def flush_paragraph() -> None:
nonlocal paragraph_lines
if not paragraph_lines:
return
block = " ".join(line.strip() for line in paragraph_lines).strip()
paragraph_lines = []
if not block:
return
prefix = context_prefix()
entries.append(f"{prefix}: {block}" if prefix else block)
in_code_block = False
for raw_line in (text or "").splitlines():
line = raw_line.rstrip()
stripped = line.strip()
if stripped.startswith("```"):
in_code_block = not in_code_block
flush_paragraph()
continue
if in_code_block:
continue
heading_match = re.match(r"^(#{1,6})\s+(.*\S)\s*$", stripped)
if heading_match:
flush_paragraph()
level = len(heading_match.group(1))
value = heading_match.group(2).strip()
while len(headings) >= level:
headings.pop()
headings.append(value)
continue
bullet_match = re.match(r"^\s*(?:[-*]|\d+\.)\s+(.*\S)\s*$", line)
if bullet_match:
flush_paragraph()
content = bullet_match.group(1).strip()
prefix = context_prefix()
entries.append(f"{prefix}: {content}" if prefix else content)
continue
if not stripped:
flush_paragraph()
continue
if stripped.startswith("|") and stripped.endswith("|"):
flush_paragraph()
continue
paragraph_lines.append(stripped)
flush_paragraph()
deduped: List[str] = []
seen = set()
for entry in entries:
normalized = normalize_text(entry)
if not normalized or normalized in seen:
continue
seen.add(normalized)
deduped.append(entry.strip())
return deduped
def parse_existing_memory_entries(path: Path) -> List[str]:
if not path.exists():
return []
raw = read_text(path)
if not raw.strip():
return []
if ENTRY_DELIMITER in raw:
return [e.strip() for e in raw.split(ENTRY_DELIMITER) if e.strip()]
return extract_markdown_entries(raw)
def merge_entries(
existing: Sequence[str],
incoming: Sequence[str],
limit: int,
) -> Tuple[List[str], Dict[str, int]]:
merged = list(existing)
seen = {normalize_text(e) for e in existing if e.strip()}
stats = {"existing": len(existing), "added": 0, "duplicates": 0, "overflowed": 0}
current_len = len(ENTRY_DELIMITER.join(merged)) if merged else 0
for entry in incoming:
normalized = normalize_text(entry)
if not normalized:
continue
if normalized in seen:
stats["duplicates"] += 1
continue
candidate_len = (
len(entry) if not merged
else current_len + len(ENTRY_DELIMITER) + len(entry)
)
if candidate_len > limit:
stats["overflowed"] += 1
continue
merged.append(entry)
seen.add(normalized)
current_len = candidate_len
stats["added"] += 1
return merged, stats
# ---------------------------------------------------------------------------
# Claude Code permission rules → Hermes command patterns
# ---------------------------------------------------------------------------
_BASH_RULE_RE = re.compile(r"^Bash\((?P<inner>.*)\)$")
def claude_rule_to_command_pattern(rule: str) -> Optional[str]:
"""Convert a Claude Code ``Bash(...)`` permission rule into a Hermes glob.
``Bash(npm run build)`` → ``npm run build``
``Bash(npm run test:*)`` → ``npm run test*`` (Claude ':*' prefix match)
``Bash(git diff *)`` → ``git diff *``
``Bash`` → None (blanket rule, too broad to import)
Non-Bash rules (``Read(...)``, ``WebFetch(...)``, ...) → None: they gate
Claude-specific tools with no command-allowlist equivalent.
"""
rule = (rule or "").strip()
m = _BASH_RULE_RE.match(rule)
if not m:
return None
inner = m.group("inner").strip()
if not inner:
return None
if inner.endswith(":*"):
inner = inner[:-2] + "*"
return inner
# ---------------------------------------------------------------------------
# Detection
# ---------------------------------------------------------------------------
def default_source_dir(agent: str) -> Path:
return Path.home() / _AGENT_DEFAULT_DIRS[agent]
def detect_agents() -> List[str]:
"""Return the list of supported agents whose default dirs exist."""
return [a for a in SUPPORTED_AGENTS if default_source_dir(a).is_dir()]
def sanitize_mcp_env(env: Any) -> Tuple[Dict[str, str], List[str]]:
"""Split an MCP server env dict into (kept, stripped-secret-names)."""
kept: Dict[str, str] = {}
stripped: List[str] = []
if not isinstance(env, dict):
return kept, stripped
for key, value in env.items():
if is_secret_key(str(key)):
stripped.append(str(key))
else:
kept[str(key)] = value
return kept, stripped
# ---------------------------------------------------------------------------
# Importer
# ---------------------------------------------------------------------------
class AgentImporter:
"""Detect/parse/map/apply importer for a single agent source tree.
``execute=False`` runs the full plan without touching disk (dry run);
``execute=True`` applies it. Every item is recorded as
imported/skipped/conflict/error with a human-readable reason so the CLI
can print a per-item report.
"""
def __init__(
self,
agent: str,
source_root: Path,
target_root: Path,
execute: bool = False,
overwrite: bool = False,
) -> None:
if agent not in SUPPORTED_AGENTS:
raise ValueError(f"Unsupported agent: {agent!r}")
self.agent = agent
self.source_root = Path(source_root)
self.target_root = Path(target_root)
self.execute = execute
self.overwrite = overwrite
self.items: List[Dict[str, Any]] = []
self.stripped_secrets: List[str] = []
# -- reporting ---------------------------------------------------------
def record(self, kind: str, source, destination, status: str,
reason: str = "", **details) -> None:
item: Dict[str, Any] = {
"kind": kind,
"source": str(source) if source else None,
"destination": str(destination) if destination else None,
"status": status,
"reason": reason,
}
if details:
item.update(details)
self.items.append(item)
def build_report(self) -> Dict[str, Any]:
summary = {"imported": 0, "skipped": 0, "conflict": 0, "error": 0}
for item in self.items:
status = item.get("status", "skipped")
summary[status] = summary.get(status, 0) + 1
report: Dict[str, Any] = {
"agent": self.agent,
"source": str(self.source_root),
"target": str(self.target_root),
"dry_run": not self.execute,
"items": self.items,
"summary": summary,
}
if self.stripped_secrets:
report["stripped_secrets"] = sorted(set(self.stripped_secrets))
return report
# -- orchestration -----------------------------------------------------
def run(self) -> Dict[str, Any]:
if not self.source_root.is_dir():
self.record("source", self.source_root, None, "error",
"Source directory does not exist")
return self.build_report()
if self.agent == "claude-code":
self._run_claude_code()
else:
self._run_codex()
return self.build_report()
def _run_claude_code(self) -> None:
settings = self._load_claude_settings()
self.import_context_file(self.source_root / "CLAUDE.md", kind="claude-md")
self.import_permission_allowlist(settings)
self.import_permission_denylist(settings)
self.import_mcp_servers(self._claude_mcp_servers(settings), kind="mcp-servers")
self.import_skills(self.source_root / "skills")
commands_dir = self.source_root / "commands"
if commands_dir.is_dir() and any(commands_dir.glob("*.md")):
self.record(
"slash-commands", commands_dir, None, "skipped",
"Claude slash commands have no direct Hermes equivalent — "
"consider converting them into skills",
)
def _run_codex(self) -> None:
config = self._load_codex_config()
self.import_context_file(self.source_root / "AGENTS.md", kind="agents-md")
mcp = config.get("mcp_servers") if isinstance(config, dict) else None
self.import_mcp_servers(mcp if isinstance(mcp, dict) else {},
kind="mcp-servers")
self.import_memories_dir(self.source_root / "memories")
self.import_skills(self.source_root / "skills")
# -- parsers (fail soft: bad files become per-item error records) -------
def _load_claude_settings(self) -> Dict[str, Any]:
path = self.source_root / "settings.json"
if not path.exists():
self.record("settings", None, None, "skipped",
"No settings.json found")
return {}
try:
data = json.loads(read_text(path))
except (json.JSONDecodeError, OSError) as exc:
self.record("settings", path, None, "error",
f"Could not parse settings.json: {exc}")
return {}
if not isinstance(data, dict):
self.record("settings", path, None, "error",
"settings.json is not a JSON object")
return {}
return data
def _claude_mcp_servers(self, settings: Dict[str, Any]) -> Dict[str, Any]:
"""Collect mcpServers from ~/.claude.json (preferred) and settings.json."""
servers: Dict[str, Any] = {}
# ~/.claude.json lives NEXT TO ~/.claude/, not inside it
claude_json = self.source_root.parent / ".claude.json"
if claude_json.exists():
try:
data = json.loads(read_text(claude_json))
if isinstance(data, dict) and isinstance(data.get("mcpServers"), dict):
servers.update(data["mcpServers"])
except (json.JSONDecodeError, OSError) as exc:
self.record("mcp-servers", claude_json, None, "error",
f"Could not parse .claude.json: {exc}")
from_settings = settings.get("mcpServers")
if isinstance(from_settings, dict):
for name, srv in from_settings.items():
servers.setdefault(name, srv)
return servers
def _load_codex_config(self) -> Dict[str, Any]:
path = self.source_root / "config.toml"
if not path.exists():
self.record("config", None, None, "skipped",
"No config.toml found")
return {}
try:
import tomllib
data = tomllib.loads(read_text(path))
except Exception as exc:
self.record("config", path, None, "error",
f"Could not parse config.toml: {exc}")
return {}
return data if isinstance(data, dict) else {}
# -- mappers -------------------------------------------------------------
def import_context_file(self, source: Path, kind: str) -> None:
"""CLAUDE.md / AGENTS.md → memory entries in memories/MEMORY.md."""
destination = self.target_root / "memories" / "MEMORY.md"
if not source.exists():
self.record(kind, None, destination, "skipped",
f"No {source.name} found")
return
try:
incoming = extract_markdown_entries(read_text(source))
except OSError as exc:
self.record(kind, source, destination, "error",
f"Could not read file: {exc}")
return
if not incoming:
self.record(kind, source, destination, "skipped",
"No importable entries found")
return
self._merge_memory_entries(kind, source, destination, incoming)
def import_memories_dir(self, memories_dir: Path) -> None:
"""codex memories/*.md → memory entries in memories/MEMORY.md."""
destination = self.target_root / "memories" / "MEMORY.md"
if not memories_dir.is_dir():
self.record("memories", None, destination, "skipped",
"No memories directory found")
return
incoming: List[str] = []
for md_file in sorted(memories_dir.glob("*.md")):
try:
incoming.extend(extract_markdown_entries(read_text(md_file)))
except OSError as exc:
self.record("memories", md_file, destination, "error",
f"Could not read file: {exc}")
if not incoming:
self.record("memories", memories_dir, destination, "skipped",
"No importable entries found")
return
self._merge_memory_entries("memories", memories_dir, destination, incoming)
def _merge_memory_entries(self, kind: str, source: Path,
destination: Path, incoming: List[str]) -> None:
existing = parse_existing_memory_entries(destination)
merged, stats = merge_entries(existing, incoming, MEMORY_CHAR_LIMIT)
details = {
"existing_entries": stats["existing"],
"added_entries": stats["added"],
"duplicate_entries": stats["duplicates"],
"overflowed_entries": stats["overflowed"],
}
if stats["added"] == 0:
self.record(kind, source, destination, "skipped",
"No new entries to import", **details)
return
if self.execute:
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(
ENTRY_DELIMITER.join(merged) + ("\n" if merged else ""),
encoding="utf-8",
)
self.record(kind, source, destination, "imported", **details)
else:
self.record(kind, source, destination, "imported",
"Would merge entries", **details)
def import_permission_allowlist(self, settings: Dict[str, Any]) -> None:
"""settings.json permissions.allow → config.yaml command_allowlist."""
destination = self.target_root / "config.yaml"
permissions = settings.get("permissions")
allow = permissions.get("allow") if isinstance(permissions, dict) else None
if not isinstance(allow, list) or not allow:
self.record("command-allowlist", None, destination, "skipped",
"No permissions.allow rules found")
return
patterns: List[str] = []
skipped_rules: List[str] = []
for rule in allow:
if not isinstance(rule, str):
continue
pattern = claude_rule_to_command_pattern(rule)
if pattern:
patterns.append(pattern)
else:
skipped_rules.append(rule)
patterns = sorted(dict.fromkeys(patterns))
if not patterns:
self.record("command-allowlist", None, destination, "skipped",
"No Bash(...) allow rules to import",
unmapped_rules=skipped_rules)
return
config = load_yaml_file(destination)
current = config.get("command_allowlist", [])
if not isinstance(current, list):
current = []
merged = sorted(dict.fromkeys(list(current) + patterns))
added = [p for p in merged if p not in current]
if not added:
self.record("command-allowlist", "settings.json permissions.allow",
destination, "skipped", "All patterns already present")
return
details: Dict[str, Any] = {"added_patterns": added}
if skipped_rules:
details["unmapped_rules"] = skipped_rules
if self.execute:
config["command_allowlist"] = merged
dump_yaml_file(destination, config)
self.record("command-allowlist", "settings.json permissions.allow",
destination, "imported", **details)
else:
self.record("command-allowlist", "settings.json permissions.allow",
destination, "imported", "Would merge patterns", **details)
def import_permission_denylist(self, settings: Dict[str, Any]) -> None:
"""settings.json permissions.deny → config.yaml approvals.deny."""
destination = self.target_root / "config.yaml"
permissions = settings.get("permissions")
deny = permissions.get("deny") if isinstance(permissions, dict) else None
if not isinstance(deny, list) or not deny:
self.record("command-denylist", None, destination, "skipped",
"No permissions.deny rules found")
return
patterns: List[str] = []
for rule in deny:
if not isinstance(rule, str):
continue
pattern = claude_rule_to_command_pattern(rule)
if pattern:
patterns.append(pattern)
patterns = sorted(dict.fromkeys(patterns))
if not patterns:
self.record("command-denylist", None, destination, "skipped",
"No Bash(...) deny rules to import")
return
config = load_yaml_file(destination)
approvals = config.get("approvals")
if not isinstance(approvals, dict):
approvals = {}
current = approvals.get("deny", [])
if not isinstance(current, list):
current = []
merged = sorted(dict.fromkeys(list(current) + patterns))
added = [p for p in merged if p not in current]
if not added:
self.record("command-denylist", "settings.json permissions.deny",
destination, "skipped", "All patterns already present")
return
if self.execute:
approvals["deny"] = merged
config["approvals"] = approvals
dump_yaml_file(destination, config)
self.record("command-denylist", "settings.json permissions.deny",
destination, "imported", added_patterns=added)
else:
self.record("command-denylist", "settings.json permissions.deny",
destination, "imported", "Would merge patterns",
added_patterns=added)
def import_mcp_servers(self, servers: Dict[str, Any], kind: str) -> None:
"""mcpServers / [mcp_servers.*] → config.yaml mcp_servers."""
destination = self.target_root / "config.yaml"
if not servers:
self.record(kind, None, destination, "skipped",
"No MCP servers found")
return
config = load_yaml_file(destination)
existing = config.get("mcp_servers")
if not isinstance(existing, dict):
existing = {}
added = 0
for name, srv in servers.items():
if not isinstance(srv, dict):
self.record(kind, name, None, "skipped",
"Server entry is not a mapping")
continue
if name in existing and not self.overwrite:
self.record(kind, name, f"mcp_servers.{name}", "conflict",
"MCP server already exists in Hermes config")
continue
hermes_srv: Dict[str, Any] = {}
if srv.get("command"):
hermes_srv["command"] = srv["command"]
if srv.get("args"):
hermes_srv["args"] = srv["args"]
env_kept, env_stripped = sanitize_mcp_env(srv.get("env"))
if env_kept:
hermes_srv["env"] = env_kept
if env_stripped:
self.stripped_secrets.extend(
f"mcp_servers.{name}.env.{k}" for k in env_stripped
)
if srv.get("cwd"):
hermes_srv["cwd"] = srv["cwd"]
if srv.get("url"):
hermes_srv["url"] = srv["url"]
headers = srv.get("headers")
if isinstance(headers, dict):
kept_headers = {
k: v for k, v in headers.items()
if not is_secret_key(str(k))
and "authorization" not in str(k).lower()
}
if kept_headers:
hermes_srv["headers"] = kept_headers
for k in headers:
if k not in kept_headers:
self.stripped_secrets.append(
f"mcp_servers.{name}.headers.{k}"
)
if not hermes_srv:
self.record(kind, name, None, "skipped",
"Server has neither a command nor a url")
continue
existing[name] = hermes_srv
added += 1
self.record(kind, name, f"config.yaml mcp_servers.{name}",
"imported")
if added > 0 and self.execute:
config["mcp_servers"] = existing
dump_yaml_file(destination, config)
def import_skills(self, source_root: Path) -> None:
"""skills/<name>/SKILL.md dirs → HERMES_HOME/skills/<category>/<name>."""
category = _SKILL_CATEGORY[self.agent]
destination_root = self.target_root / "skills" / category
if not source_root.is_dir():
self.record("skills", None, destination_root, "skipped",
"No skills directory found")
return
skill_dirs = [
p for p in sorted(source_root.iterdir())
if p.is_dir() and (p / "SKILL.md").exists()
]
if not skill_dirs:
self.record("skills", source_root, destination_root, "skipped",
"No skills with SKILL.md found")
return
for skill_dir in skill_dirs:
destination = destination_root / skill_dir.name
if destination.exists() and not self.overwrite:
self.record("skill", skill_dir, destination, "conflict",
"Destination skill already exists")
continue
if self.execute:
destination.parent.mkdir(parents=True, exist_ok=True)
if destination.exists():
shutil.rmtree(destination)
shutil.copytree(skill_dir, destination)
self.record("skill", skill_dir, destination, "imported")
else:
self.record("skill", skill_dir, destination, "imported",
"Would copy skill directory")
# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
def import_agent_command(args) -> None:
"""Handle ``hermes import-agent`` (invoked from hermes_cli.main)."""
from hermes_cli.config import get_config_path, load_config, save_config
from hermes_constants import get_hermes_home
from hermes_cli.setup import (
Colors,
color,
print_header,
print_info,
print_success,
print_error,
prompt_yes_no,
)
agent = getattr(args, "agent", None)
explicit_source = getattr(args, "source", None)
dry_run = getattr(args, "dry_run", False)
overwrite = getattr(args, "overwrite", False)
auto_yes = getattr(args, "yes", False)
# -- detect --------------------------------------------------------------
if agent is None:
detected = detect_agents()
if not detected:
print()
print_error("No supported agent setup found (~/.claude or ~/.codex).")
print_info("Specify one explicitly: hermes import-agent claude-code --source /path")
return
if len(detected) > 1 and explicit_source is None:
print()
print_info("Multiple agent setups detected: " + ", ".join(detected))
print_info("Pick one: hermes import-agent claude-code or hermes import-agent codex")
return
agent = detected[0]
source_dir = Path(explicit_source) if explicit_source else default_source_dir(agent)
print()
print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA))
print(color("│ ⚕ Hermes — Import From Another Agent │", Colors.MAGENTA))
print(color("└─────────────────────────────────────────────────────────┘", Colors.MAGENTA))
if not source_dir.is_dir():
print()
print_error(f"Agent directory not found: {source_dir}")
print_info("Specify a custom path: hermes import-agent "
f"{agent} --source /path/to/{_AGENT_DEFAULT_DIRS[agent]}")
return
hermes_home = get_hermes_home()
print()
print_header("Import Settings")
print_info(f"Agent: {agent}")
print_info(f"Source: {source_dir}")
print_info(f"Target: {hermes_home}")
print_info(f"Overwrite: {'yes' if overwrite else 'no (skip conflicts)'}")
print_info("Secrets: never imported — run 'hermes setup' for credentials")
# Ensure config.yaml exists before the import tries to merge into it
config_path = get_config_path()
if not config_path.exists():
save_config(load_config())
# -- Phase 1: preview (always) --------------------------------------------
try:
preview = AgentImporter(
agent=agent,
source_root=source_dir.resolve(),
target_root=hermes_home.resolve(),
execute=False,
overwrite=overwrite,
).run()
except Exception as e:
print()
print_error(f"Import preview failed: {e}")
logger.debug("import-agent preview error", exc_info=True)
return
summary = preview.get("summary", {})
if summary.get("imported", 0) == 0 and summary.get("conflict", 0) == 0:
print()
print_info(f"Nothing to import from {agent}.")
print_import_report(preview, dry_run=True)
return
print()
print_header(f"Import Preview — {summary.get('imported', 0)} item(s) would be imported")
print_info("No changes have been made yet. Review the list below:")
print_import_report(preview, dry_run=True)
if dry_run:
return
# -- Phase 2: confirm and execute -----------------------------------------
print()
if not auto_yes:
if not sys.stdin.isatty():
print_info("Non-interactive session — preview only.")
print_info(f"To execute, re-run with: hermes import-agent {agent} --yes")
return
if not prompt_yes_no("Proceed with import?", default=True):
print_info("Import cancelled.")
return
try:
report = AgentImporter(
agent=agent,
source_root=source_dir.resolve(),
target_root=hermes_home.resolve(),
execute=True,
overwrite=overwrite,
).run()
except Exception as e:
print()
print_error(f"Import failed: {e}")
logger.debug("import-agent error", exc_info=True)
return
print_import_report(report, dry_run=False)
print()
print_success("Import complete.")
print_info("API keys and credentials were NOT imported — run 'hermes setup' "
"to configure providers, or add them to ~/.hermes/.env.")
def print_import_report(report: Dict[str, Any], dry_run: bool) -> None:
"""Print a formatted per-item import report (claw-migrate style)."""
from hermes_cli.setup import Colors, color, print_header, print_info
summary = report.get("summary", {})
print()
if dry_run:
print_header("Dry Run Results")
print_info("No files were modified. This is a preview of what would happen.")
else:
print_header("Import Results")
print()
items = report.get("items", [])
groups = (
("imported", Colors.GREEN, "✓ Would import" if dry_run else "✓ Imported"),
("conflict", Colors.YELLOW, "⚠ Conflicts (skipped — use --overwrite to force)"),
("skipped", Colors.DIM, "─ Skipped"),
("error", Colors.RED, "✗ Errors"),
)
for status, col, label in groups:
group_items = [i for i in items if i.get("status") == status]
if not group_items:
continue
print(color(f" {label}:", col))
for item in group_items:
kind = item.get("kind", "unknown")
if status == "imported":
dest = item.get("destination") or ""
dest_short = str(dest).replace(str(Path.home()), "~")
print(f" {kind:<22s}{dest_short}")
else:
reason = item.get("reason", "")
print(f" {kind:<22s} {reason}")
print()
stripped = report.get("stripped_secrets") or []
if stripped:
print(color(" ⚷ Secrets stripped (never imported):", Colors.YELLOW))
for name in stripped:
print(f" {name}")
print_info("Re-add credentials deliberately via 'hermes setup' or ~/.hermes/.env.")
print()
parts = []
if summary.get("imported"):
action = "would import" if dry_run else "imported"
parts.append(f"{summary['imported']} {action}")
if summary.get("conflict"):
parts.append(f"{summary['conflict']} conflict(s)")
if summary.get("skipped"):
parts.append(f"{summary['skipped']} skipped")
if summary.get("error"):
parts.append(f"{summary['error']} error(s)")
if parts:
print_info(f"Summary: {', '.join(parts)}")