Files
hermes-agent/hermes_cli/agent_import.py

892 lines
35 KiB
Python

"""hermes import-agent — import Claude Code / Codex CLI setups into Hermes.
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
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple
from utils import atomic_write_text, atomic_yaml_write
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")
class ConfigReadError(RuntimeError):
"""An existing config file is present but cannot be read or parsed.
Signals that a read-modify-write round trip must be abandoned: the caller has no idea what the
file holds, so writing a merged result back would replace real settings with only the keys it
merged.
"""
def load_yaml_file(path: Path) -> Dict[str, Any]:
"""Load a YAML mapping, distinguishing "absent" from "unreadable".
- Absent, or present but empty -> ``{}``; first-time creation still works. - Present but
unreadable, unparseable, or not a mapping -> raise :class:`ConfigReadError` so the caller
refuses and leaves the file byte-identical.
"""
import yaml
if not path.exists():
return {}
fix_hint = "Fix it with `hermes config edit` (or move it aside), then re-run the import."
try:
raw = read_text(path)
except OSError as exc:
raise ConfigReadError(
f"Refusing to overwrite {path}: the existing file cannot be read "
f"({exc}). Fix the file permissions or move it aside first."
) from exc
try:
data = yaml.safe_load(raw)
except yaml.YAMLError as exc:
raise ConfigReadError(
f"Refusing to overwrite {path}: the existing file is not valid YAML ({exc}). {fix_hint}"
) from exc
# An empty file parses to None — a legitimate state with nothing to lose.
if data is None:
return {}
if not isinstance(data, dict):
raise ConfigReadError(
f"Refusing to overwrite {path}: expected the existing file to hold a "
f"YAML mapping but found {type(data).__name__}. {fix_hint}"
)
return data
def dump_yaml_file(path: Path, data: Dict[str, Any]) -> None:
"""Write ``data`` as YAML atomically (temp file + fsync + rename).
Only ever reached after :func:`load_yaml_file` has successfully read the same path, so the
mapping being written is the real file's content plus the merged section — never a silently-
empty stand-in.
"""
atomic_yaml_write(path, data)
# ---------------------------------------------------------------------------
# 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.
"""
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 add_entry(content: str) -> None:
prefix = context_prefix()
entries.append(f"{prefix}: {content}" if prefix else content)
def flush_paragraph() -> None:
nonlocal paragraph_lines
block = " ".join(line.strip() for line in paragraph_lines).strip()
paragraph_lines = []
if block:
add_entry(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()
add_entry(bullet_match.group(1).strip())
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]:
"""Parse the DESTINATION memory store into entries.
``memories/MEMORY.md`` is the entry-delimited store written by ``MemoryStore._write_file``
(tools/memory_tool.py), not a markdown document, so this splits on ``ENTRY_DELIMITER`` only —
exactly what ``MemoryStore._parse_entries`` does.
Do NOT fall back to :func:`extract_markdown_entries` here. That extractor is correct for
CLAUDE.md / AGENTS.md *sources*, but it drops fenced code blocks and table rows and splits a
block into one entry per bullet — and the merged result is written straight back over the user's
store, so the loss is permanent.
"""
if not path.exists():
return []
raw = read_text(path)
if not raw.strip():
return []
return [e.strip() for e in raw.split(ENTRY_DELIMITER) if e.strip()]
def backup_memory_file(path: Path) -> Optional[Path]:
"""Snapshot ``path`` before a destructive rewrite; return the backup path.
Restores parity with the openclaw migration script this module was ported from, which calls
``maybe_backup(destination)`` before rewriting a memory store. Uses the same
``<name>.bak.<unix_ts>`` naming as ``MemoryStore._backup_drifted_file``. Returns None when there
is nothing to back up.
"""
if not path.exists():
return None
backup = path.with_suffix(path.suffix + f".bak.{int(time.time())}")
shutil.copy2(path, backup)
return backup
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 test:*)`` -> ``npm run test*`` (Claude ':*' is a prefix match). Bare ``Bash``
and non-Bash rules (``Read(...)``, ``WebFetch(...)``) return None: the former is too broad to
import, the latter 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)."""
if not isinstance(env, dict):
return {}, []
kept = {str(k): v for k, v in env.items() if not is_secret_key(str(k))}
return kept, [str(k) for k in env if str(k) not in kept]
def _copy_skill_dir(skill_dir: Path, destination: Path) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
if destination.exists():
shutil.rmtree(destination)
shutil.copytree(skill_dir, destination)
def _translate_mcp_server(name: str, srv: Dict[str, Any]) -> Tuple[Dict[str, Any], List[str]]:
"""Map one Claude/Codex MCP server entry to Hermes shape; returns (server, stripped secret paths)."""
hermes_srv: Dict[str, Any] = {}
stripped: List[str] = []
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
stripped.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
stripped.extend(f"mcp_servers.{name}.headers.{k}" for k in headers if k not in kept_headers)
return hermes_srv, 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). Every item is recorded
as imported/skipped/conflict/error with a 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,
}
item.update(details)
self.items.append(item)
def load_target_config(self, kind: str, source, destination: Path
) -> Optional[Dict[str, Any]]:
"""Read the destination config.yaml, or record a refusal and return None.
Deliberately runs in dry-run too: ``--dry-run`` must report the refusal, not preview an
``imported`` that would destroy the config.
"""
try:
return load_yaml_file(destination)
except ConfigReadError as exc:
self.record(kind, source, destination, "error", str(exc))
return None
def apply(self, kind: str, source, destination, would: str, action,
details: Optional[Dict[str, Any]] = None) -> None:
"""Record ``imported`` (reason ``would`` in dry-run); in execute mode run ``action`` first.
``action`` may add keys to ``details`` (shared by reference) and returns an error string
to record ``error`` instead, or None on success.
"""
details = details or {}
if not self.execute:
self.record(kind, source, destination, "imported", would, **details)
return
error = action()
self.record(kind, source, destination, "error" if error else "imported", error or "", **details)
def build_report(self) -> Dict[str, Any]:
summary = {"imported": 0, "skipped": 0, "conflict": 0, "error": 0}
for item in self.items:
summary[item["status"]] = summary.get(item["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()
{"claude-code": self._run_claude_code, "codex": self._run_codex}[self.agent]()
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")
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) -------
_JSON_ERRORS = (json.JSONDecodeError, OSError)
def _load_claude_settings(self) -> Dict[str, Any]:
return self._load_source_mapping(
"settings", self.source_root / "settings.json", json.loads, self._JSON_ERRORS,
record_missing=True, non_mapping_error="settings.json is not a JSON object",
)
def _load_codex_config(self) -> Dict[str, Any]:
import tomllib
return self._load_source_mapping(
"config", self.source_root / "config.toml", tomllib.loads, Exception,
record_missing=True,
)
def _load_source_mapping(self, kind: str, path: Path, parse, errors, *,
record_missing: bool = False,
non_mapping_error: str = "") -> Dict[str, Any]:
"""Parse ``path`` into a mapping; problems become per-item error records, result ``{}``."""
if not path.exists():
if record_missing:
self.record(kind, None, None, "skipped", f"No {path.name} found")
return {}
try:
data = parse(read_text(path))
except errors as exc:
self.record(kind, path, None, "error", f"Could not parse {path.name}: {exc}")
return {}
if isinstance(data, dict):
return data
if non_mapping_error:
self.record(kind, path, None, "error", non_mapping_error)
return {}
def _claude_mcp_servers(self, settings: Dict[str, Any]) -> Dict[str, Any]:
"""Collect mcpServers from ~/.claude.json (preferred) and settings.json."""
# ~/.claude.json lives NEXT TO ~/.claude/, not inside it
data = self._load_source_mapping(
"mcp-servers", self.source_root.parent / ".claude.json", json.loads, self._JSON_ERRORS)
servers: Dict[str, Any] = {}
if isinstance(data.get("mcpServers"), dict):
servers.update(data["mcpServers"])
from_settings = settings.get("mcpServers")
if isinstance(from_settings, dict):
for name, srv in from_settings.items():
servers.setdefault(name, srv)
return servers
# -- mappers -------------------------------------------------------------
def import_context_file(self, source: Path, kind: str) -> None:
"""CLAUDE.md / AGENTS.md → memory entries in memories/MEMORY.md."""
if not source.exists():
self.record(kind, None, self.target_root / "memories" / "MEMORY.md",
"skipped", f"No {source.name} found")
return
self._import_markdown_files(kind, source, [source], single_file=True)
def import_memories_dir(self, memories_dir: Path) -> None:
"""codex memories/*.md → memory entries in memories/MEMORY.md."""
if not memories_dir.is_dir():
self.record("memories", None, self.target_root / "memories" / "MEMORY.md",
"skipped", "No memories directory found")
return
self._import_markdown_files("memories", memories_dir, sorted(memories_dir.glob("*.md")))
def _import_markdown_files(self, kind: str, source: Path, files: List[Path],
single_file: bool = False) -> None:
"""Extract entries from ``files`` and merge them into memories/MEMORY.md.
An unreadable file records an error; a directory import then still reports "no entries"
when nothing was extracted, while a single-file import stops at the error.
"""
destination = self.target_root / "memories" / "MEMORY.md"
incoming: List[str] = []
failed = False
for md_file in files:
try:
incoming.extend(extract_markdown_entries(read_text(md_file)))
except OSError as exc:
failed = True
self.record(kind, md_file, destination, "error", f"Could not read file: {exc}")
if not incoming:
if not (failed and single_file):
self.record(kind, source, destination, "skipped", "No importable entries found")
return
self._merge_memory_entries(kind, source, 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
def write() -> Optional[str]:
destination.parent.mkdir(parents=True, exist_ok=True)
try:
backup = backup_memory_file(destination)
except OSError as exc:
# Never rewrite the store when the safety net failed.
return f"Could not back up existing memory file: {exc}"
if backup is not None:
details["backup"] = str(backup)
try:
atomic_write_text(destination, ENTRY_DELIMITER.join(merged) + ("\n" if merged else ""))
except OSError as exc:
return f"Could not write merged memory file: {exc}"
return None
self.apply(kind, source, destination, "Would merge entries", write, details)
# (settings key, item kind, config path, dry-run tracks unmapped rules)
_PERMISSION_RULES = {
"allow": ("command-allowlist", ("command_allowlist",), True),
"deny": ("command-denylist", ("approvals", "deny"), False),
}
def import_permission_allowlist(self, settings: Dict[str, Any]) -> None:
"""settings.json permissions.allow → config.yaml command_allowlist."""
self._import_permission_rules(settings, "allow")
def import_permission_denylist(self, settings: Dict[str, Any]) -> None:
"""settings.json permissions.deny → config.yaml approvals.deny."""
self._import_permission_rules(settings, "deny")
def _import_permission_rules(self, settings: Dict[str, Any], key: str) -> None:
kind, config_path, track_unmapped = self._PERMISSION_RULES[key]
label = f"settings.json permissions.{key}"
destination = self.target_root / "config.yaml"
permissions = settings.get("permissions")
rules = permissions.get(key) if isinstance(permissions, dict) else None
if not isinstance(rules, list) or not rules:
self.record(kind, None, destination, "skipped", f"No permissions.{key} rules found")
return
patterns: List[str] = []
skipped_rules: List[str] = []
for rule in rules:
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))
unmapped: Dict[str, Any] = {"unmapped_rules": skipped_rules} if track_unmapped else {}
if not patterns:
self.record(kind, None, destination, "skipped",
f"No Bash(...) {key} rules to import", **unmapped)
return
if not skipped_rules:
unmapped = {}
config = self.load_target_config(kind, label, destination)
if config is None:
return
# Walk to the list's parent mapping, materializing missing/invalid levels.
parent: Dict[str, Any] = config
for part in config_path[:-1]:
child = parent.get(part)
if not isinstance(child, dict):
child = {}
parent[part] = child
parent = child
current = parent.get(config_path[-1], [])
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(kind, label, destination, "skipped", "All patterns already present")
return
def write() -> None:
parent[config_path[-1]] = merged
dump_yaml_file(destination, config)
self.apply(kind, label, destination, "Would merge patterns", write,
{"added_patterns": added, **unmapped})
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 = self.load_target_config(kind, None, destination)
if config is None:
return
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, stripped = _translate_mcp_server(name, srv)
self.stripped_secrets.extend(stripped)
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
self.apply("skill", skill_dir, destination, "Would copy skill directory",
lambda: _copy_skill_dir(skill_dir, destination))
# ---------------------------------------------------------------------------
# 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, explicit_source = args.agent, args.source
dry_run, overwrite, auto_yes = args.dry_run, args.overwrite, args.yes
# -- 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(f"Specify a custom path: hermes import-agent {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) --------------------------------------------
def run_import(execute: bool) -> Dict[str, Any]:
return AgentImporter(
agent=agent,
source_root=source_dir.resolve(),
target_root=hermes_home.resolve(),
execute=execute,
overwrite=overwrite,
).run()
try:
preview = run_import(execute=False)
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 = run_import(execute=True)
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:
tail = (
"→ " + str(item.get("destination") or "").replace(str(Path.home()), "~")
if status == "imported" else f" {item.get('reason', '')}"
)
print(f" {item.get('kind', 'unknown'):<22s} {tail}")
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()
labels = (
("imported", "would import" if dry_run else "imported"),
("conflict", "conflict(s)"), ("skipped", "skipped"), ("error", "error(s)"),
)
parts = [f"{summary[k]} {label}" for k, label in labels if summary.get(k)]
if parts:
print_info(f"Summary: {', '.join(parts)}")