diff --git a/hermes_cli/agent_import.py b/hermes_cli/agent_import.py new file mode 100644 index 00000000000..bd686e59348 --- /dev/null +++ b/hermes_cli/agent_import.py @@ -0,0 +1,905 @@ +"""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//SKILL.md → HERMES_HOME/skills/claude-code-imports// + +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//SKILL.md → HERMES_HOME/skills/codex-imports// + +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.*)\)$") + + +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//SKILL.md dirs → HERMES_HOME/skills//.""" + 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)}") diff --git a/hermes_cli/subcommands/import_agent.py b/hermes_cli/subcommands/import_agent.py new file mode 100644 index 00000000000..c20d10b053e --- /dev/null +++ b/hermes_cli/subcommands/import_agent.py @@ -0,0 +1,49 @@ +"""``hermes import-agent`` subcommand parser. + +Follows the ``hermes claw`` pattern (see ``hermes_cli/subcommands/claw.py``): +parser building lives here, the handler is injected to avoid importing +``main``, and the import logic itself lives in ``hermes_cli/agent_import.py``. +""" + +from __future__ import annotations + +from typing import Callable + + +def build_import_agent_parser(subparsers, *, cmd_import_agent: Callable) -> None: + """Attach the ``import-agent`` subcommand to ``subparsers``.""" + parser = subparsers.add_parser( + "import-agent", + help="Import a Claude Code or Codex CLI setup into Hermes", + description=( + "One-command import of another coding agent's setup into Hermes. " + "Maps CLAUDE.md/AGENTS.md instructions, permission allowlists, MCP " + "servers, skills, and memories into their Hermes equivalents. " + "Always shows a preview before making changes. API keys and " + "credentials are never imported — run 'hermes setup' for those." + ), + ) + parser.add_argument( + "agent", + nargs="?", + choices=["claude-code", "codex"], + help="Which agent to import from (default: auto-detect ~/.claude or ~/.codex)", + ) + parser.add_argument( + "--source", + help="Path to the agent's config directory (default: ~/.claude or ~/.codex)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview only — stop after showing what would be imported", + ) + parser.add_argument( + "--overwrite", + action="store_true", + help="Overwrite existing Hermes items on name conflicts (default: skip)", + ) + parser.add_argument( + "--yes", "-y", action="store_true", help="Skip confirmation prompts" + ) + parser.set_defaults(func=cmd_import_agent) diff --git a/tests/hermes_cli/test_agent_import.py b/tests/hermes_cli/test_agent_import.py new file mode 100644 index 00000000000..b2b8dd46211 --- /dev/null +++ b/tests/hermes_cli/test_agent_import.py @@ -0,0 +1,541 @@ +"""Tests for hermes_cli.agent_import — ``hermes import-agent``. + +Covers: source detection, Claude Code and Codex parsing, mapping into the +real Hermes stores (memories/MEMORY.md, config.yaml command_allowlist / +approvals.deny / mcp_servers, skills/), dry-run write-nothing guarantees, +malformed-input skip reports, and the never-import-secrets rule. + +Uses the profile_env fixture pattern from tests/hermes_cli/test_profiles.py: +Path.home() and HERMES_HOME are redirected to tmp_path so nothing touches +the real ~/.hermes. +""" + +import json +from pathlib import Path + +import pytest +import yaml + +from hermes_cli.agent_import import ( + AgentImporter, + claude_rule_to_command_pattern, + detect_agents, + extract_markdown_entries, + is_secret_key, + sanitize_mcp_env, +) + + +# --------------------------------------------------------------------------- +# Shared fixture: redirect Path.home() and HERMES_HOME (profile_env pattern) +# --------------------------------------------------------------------------- + +@pytest.fixture() +def profile_env(tmp_path, monkeypatch): + """Isolated environment: Path.home() -> tmp_path, HERMES_HOME -> tmp/.hermes.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + default_home = tmp_path / ".hermes" + default_home.mkdir(exist_ok=True) + monkeypatch.setenv("HERMES_HOME", str(default_home)) + return tmp_path + + +@pytest.fixture() +def hermes_home(profile_env): + return profile_env / ".hermes" + + +# --------------------------------------------------------------------------- +# Fake source trees +# --------------------------------------------------------------------------- + +CLAUDE_MD = """# Global instructions + +## Style +- Always use type hints +- Prefer pathlib over os.path + +Run the linter before committing. +""" + +AGENTS_MD = """# Codex rules + +- Never force-push to main +- Keep commits atomic +""" + + +@pytest.fixture() +def claude_tree(profile_env): + """Build a fake ~/.claude tree (plus sibling ~/.claude.json).""" + root = profile_env / ".claude" + root.mkdir() + (root / "CLAUDE.md").write_text(CLAUDE_MD, encoding="utf-8") + (root / "settings.json").write_text(json.dumps({ + "permissions": { + "allow": [ + "Bash(npm run build)", + "Bash(npm run test:*)", + "Bash(git diff *)", + "Read(~/.zshrc)", # non-Bash → unmapped + ], + "deny": [ + "Bash(rm -rf *)", + "WebFetch", # non-Bash → dropped + ], + }, + "mcpServers": { + "settings-server": {"command": "uvx", "args": ["settings-mcp"]}, + }, + }), encoding="utf-8") + # mcpServers in the sibling ~/.claude.json (Claude's primary MCP store) + (profile_env / ".claude.json").write_text(json.dumps({ + "mcpServers": { + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_TOKEN": "ghp_SECRET123", + "GITHUB_HOST": "github.example.com", + }, + }, + "remote": { + "url": "https://mcp.example.com/sse", + "headers": { + "Authorization": "Bearer abc123", + "X-Region": "us-east", + }, + }, + }, + }), encoding="utf-8") + # A credentials file that must never be read/imported + (root / ".credentials.json").write_text( + json.dumps({"api_key": "sk-ant-SUPERSECRET"}), encoding="utf-8") + # Skills + skill = root / "skills" / "deploy-helper" + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text( + "---\nname: deploy-helper\n---\n\nDeploy things.\n", encoding="utf-8") + (root / "skills" / "not-a-skill").mkdir() # no SKILL.md → ignored + # Slash commands (reported as skipped) + commands = root / "commands" + commands.mkdir() + (commands / "review.md").write_text("Review this PR", encoding="utf-8") + return root + + +@pytest.fixture() +def codex_tree(profile_env): + """Build a fake ~/.codex tree.""" + root = profile_env / ".codex" + root.mkdir() + (root / "AGENTS.md").write_text(AGENTS_MD, encoding="utf-8") + (root / "config.toml").write_text( + 'model = "gpt-5"\n' + 'approval_policy = "on-request"\n' + "\n" + "[mcp_servers.docs]\n" + 'command = "uvx"\n' + 'args = ["docs-mcp"]\n' + "\n" + "[mcp_servers.docs.env]\n" + 'DOCS_API_KEY = "secret-value"\n' + 'DOCS_REGION = "eu"\n', + encoding="utf-8", + ) + (root / "auth.json").write_text( + json.dumps({"OPENAI_API_KEY": "sk-SECRET"}), encoding="utf-8") + memories = root / "memories" + memories.mkdir() + (memories / "2026-01-01.md").write_text( + "- User prefers tabs over spaces\n- Project uses PostgreSQL\n", + encoding="utf-8", + ) + skill = root / "skills" / "db-migrate" + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text( + "---\nname: db-migrate\n---\n\nMigrate databases.\n", encoding="utf-8") + return root + + +def snapshot_tree(root: Path) -> dict: + """Map of relative-path -> bytes for every file under root.""" + return { + str(p.relative_to(root)): p.read_bytes() + for p in sorted(root.rglob("*")) if p.is_file() + } + + +def run_import(agent, source, hermes_home, execute, overwrite=False): + return AgentImporter( + agent=agent, + source_root=source, + target_root=hermes_home, + execute=execute, + overwrite=overwrite, + ).run() + + +# --------------------------------------------------------------------------- +# Detection & helpers +# --------------------------------------------------------------------------- + +class TestDetection: + def test_detects_claude_and_codex(self, claude_tree, codex_tree): + assert detect_agents() == ["claude-code", "codex"] + + def test_detects_nothing_when_absent(self, profile_env): + assert detect_agents() == [] + + def test_unsupported_agent_raises(self, hermes_home, tmp_path): + with pytest.raises(ValueError): + AgentImporter("cursor", tmp_path, hermes_home) + + +class TestRuleMapping: + def test_bash_rule_plain(self): + assert claude_rule_to_command_pattern("Bash(npm run build)") == "npm run build" + + def test_bash_rule_colon_star_prefix(self): + assert claude_rule_to_command_pattern("Bash(npm run test:*)") == "npm run test*" + + def test_non_bash_rule_is_none(self): + assert claude_rule_to_command_pattern("Read(~/.zshrc)") is None + assert claude_rule_to_command_pattern("WebFetch") is None + + def test_blanket_bash_is_none(self): + assert claude_rule_to_command_pattern("Bash()") is None + + +class TestSecretDetection: + @pytest.mark.parametrize("key", [ + "GITHUB_TOKEN", "OPENAI_API_KEY", "MY_SECRET", "DB_PASSWORD", + "AWS_ACCESS_KEY", "AUTH_HEADER", "APIKEY", + ]) + def test_secret_keys(self, key): + assert is_secret_key(key) + + @pytest.mark.parametrize("key", ["GITHUB_HOST", "REGION", "DEBUG", "PORT"]) + def test_non_secret_keys(self, key): + assert not is_secret_key(key) + + def test_sanitize_env_splits(self): + kept, stripped = sanitize_mcp_env( + {"API_TOKEN": "x", "HOST": "h", "MY_KEY": "k"}) + assert kept == {"HOST": "h"} + assert sorted(stripped) == ["API_TOKEN", "MY_KEY"] + + +class TestMarkdownEntries: + def test_extracts_bullets_and_paragraphs(self): + entries = extract_markdown_entries(CLAUDE_MD) + assert any("type hints" in e for e in entries) + assert any("linter" in e for e in entries) + + def test_heading_context_prefix(self): + entries = extract_markdown_entries(CLAUDE_MD) + assert any(e.startswith("Global instructions > Style:") for e in entries) + + +# --------------------------------------------------------------------------- +# Dry run writes NOTHING +# --------------------------------------------------------------------------- + +class TestDryRun: + def test_claude_dry_run_writes_nothing(self, claude_tree, hermes_home): + before = snapshot_tree(hermes_home) + report = run_import("claude-code", claude_tree, hermes_home, execute=False) + assert snapshot_tree(hermes_home) == before + assert report["dry_run"] is True + assert report["summary"]["imported"] > 0 + + def test_codex_dry_run_writes_nothing(self, codex_tree, hermes_home): + before = snapshot_tree(hermes_home) + report = run_import("codex", codex_tree, hermes_home, execute=False) + assert snapshot_tree(hermes_home) == before + assert report["dry_run"] is True + assert report["summary"]["imported"] > 0 + + def test_dry_run_and_real_run_plan_same_items(self, claude_tree, hermes_home): + preview = run_import("claude-code", claude_tree, hermes_home, execute=False) + applied = run_import("claude-code", claude_tree, hermes_home, execute=True) + pk = [(i["kind"], i["status"]) for i in preview["items"]] + ak = [(i["kind"], i["status"]) for i in applied["items"]] + assert pk == ak + + +# --------------------------------------------------------------------------- +# Claude Code real run — every item lands in the right store +# --------------------------------------------------------------------------- + +class TestClaudeCodeImport: + @pytest.fixture() + def report(self, claude_tree, hermes_home): + return run_import("claude-code", claude_tree, hermes_home, execute=True) + + def test_claude_md_becomes_memory_entries(self, report, hermes_home): + memory = (hermes_home / "memories" / "MEMORY.md").read_text(encoding="utf-8") + assert "type hints" in memory + assert "§" in memory # entry-delimited store format + + def test_allowlist_lands_in_config_yaml(self, report, hermes_home): + config = yaml.safe_load((hermes_home / "config.yaml").read_text()) + allow = config["command_allowlist"] + assert "npm run build" in allow + assert "npm run test*" in allow + assert "git diff *" in allow + # non-Bash rules must not leak in + assert not any("Read(" in p for p in allow) + + def test_denylist_lands_in_approvals_deny(self, report, hermes_home): + config = yaml.safe_load((hermes_home / "config.yaml").read_text()) + assert "rm -rf *" in config["approvals"]["deny"] + + def test_mcp_servers_from_claude_json_and_settings(self, report, hermes_home): + config = yaml.safe_load((hermes_home / "config.yaml").read_text()) + servers = config["mcp_servers"] + assert servers["github"]["command"] == "npx" + assert servers["remote"]["url"] == "https://mcp.example.com/sse" + assert servers["settings-server"]["command"] == "uvx" + + def test_skill_copied_into_category_dir(self, report, hermes_home): + dest = hermes_home / "skills" / "claude-code-imports" / "deploy-helper" / "SKILL.md" + assert dest.exists() + assert "Deploy things" in dest.read_text(encoding="utf-8") + + def test_dir_without_skill_md_not_copied(self, report, hermes_home): + assert not (hermes_home / "skills" / "claude-code-imports" / "not-a-skill").exists() + + def test_slash_commands_reported_skipped(self, report): + items = {i["kind"]: i for i in report["items"]} + assert items["slash-commands"]["status"] == "skipped" + + +# --------------------------------------------------------------------------- +# Codex real run +# --------------------------------------------------------------------------- + +class TestCodexImport: + @pytest.fixture() + def report(self, codex_tree, hermes_home): + return run_import("codex", codex_tree, hermes_home, execute=True) + + def test_agents_md_becomes_memory_entries(self, report, hermes_home): + memory = (hermes_home / "memories" / "MEMORY.md").read_text(encoding="utf-8") + assert "force-push" in memory + + def test_memories_dir_merged(self, report, hermes_home): + memory = (hermes_home / "memories" / "MEMORY.md").read_text(encoding="utf-8") + assert "tabs over spaces" in memory + assert "PostgreSQL" in memory + + def test_mcp_servers_from_config_toml(self, report, hermes_home): + config = yaml.safe_load((hermes_home / "config.yaml").read_text()) + docs = config["mcp_servers"]["docs"] + assert docs["command"] == "uvx" + assert docs["args"] == ["docs-mcp"] + # non-secret env survives, secret is stripped + assert docs["env"] == {"DOCS_REGION": "eu"} + + def test_skill_copied(self, report, hermes_home): + assert (hermes_home / "skills" / "codex-imports" / "db-migrate" / "SKILL.md").exists() + + +# --------------------------------------------------------------------------- +# Secrets are never copied +# --------------------------------------------------------------------------- + +class TestSecretsNeverImported: + def test_no_secret_values_anywhere_claude(self, claude_tree, hermes_home): + run_import("claude-code", claude_tree, hermes_home, execute=True) + blob = "".join( + p.read_text(encoding="utf-8", errors="replace") + for p in hermes_home.rglob("*") if p.is_file() + ) + assert "ghp_SECRET123" not in blob + assert "Bearer abc123" not in blob + assert "sk-ant-SUPERSECRET" not in blob + + def test_no_secret_values_anywhere_codex(self, codex_tree, hermes_home): + run_import("codex", codex_tree, hermes_home, execute=True) + blob = "".join( + p.read_text(encoding="utf-8", errors="replace") + for p in hermes_home.rglob("*") if p.is_file() + ) + assert "secret-value" not in blob + assert "sk-SECRET" not in blob + + def test_stripped_secrets_reported(self, claude_tree, hermes_home): + report = run_import("claude-code", claude_tree, hermes_home, execute=True) + stripped = report.get("stripped_secrets", []) + assert "mcp_servers.github.env.GITHUB_TOKEN" in stripped + assert any("Authorization" in s for s in stripped) + + def test_non_secret_header_kept(self, claude_tree, hermes_home): + run_import("claude-code", claude_tree, hermes_home, execute=True) + config = yaml.safe_load((hermes_home / "config.yaml").read_text()) + assert config["mcp_servers"]["remote"]["headers"] == {"X-Region": "us-east"} + + +# --------------------------------------------------------------------------- +# Malformed inputs: per-item skip/error reports, no crashes +# --------------------------------------------------------------------------- + +class TestMalformedInputs: + def test_bad_settings_json_reports_error(self, profile_env, hermes_home): + root = profile_env / ".claude" + root.mkdir() + (root / "settings.json").write_text("{not json!!", encoding="utf-8") + (root / "CLAUDE.md").write_text("- still importable\n", encoding="utf-8") + report = run_import("claude-code", root, hermes_home, execute=True) + errors = [i for i in report["items"] if i["status"] == "error"] + assert any(i["kind"] == "settings" for i in errors) + # CLAUDE.md still imported despite bad settings.json + assert "still importable" in ( + hermes_home / "memories" / "MEMORY.md").read_text() + + def test_bad_claude_json_reports_error(self, profile_env, hermes_home): + root = profile_env / ".claude" + root.mkdir() + (profile_env / ".claude.json").write_text("][", encoding="utf-8") + (root / "CLAUDE.md").write_text("- an entry\n", encoding="utf-8") + report = run_import("claude-code", root, hermes_home, execute=True) + assert any( + i["status"] == "error" and ".claude.json" in (i["source"] or "") + for i in report["items"] + ) + assert report["summary"]["imported"] >= 1 + + def test_bad_config_toml_reports_error(self, profile_env, hermes_home): + root = profile_env / ".codex" + root.mkdir() + (root / "config.toml").write_text("[[[[not toml", encoding="utf-8") + (root / "AGENTS.md").write_text("- rule one\n", encoding="utf-8") + report = run_import("codex", root, hermes_home, execute=True) + errors = [i for i in report["items"] if i["status"] == "error"] + assert any(i["kind"] == "config" for i in errors) + assert "rule one" in (hermes_home / "memories" / "MEMORY.md").read_text() + + def test_missing_source_dir_is_error_not_crash(self, profile_env, hermes_home): + report = run_import( + "claude-code", profile_env / "nope", hermes_home, execute=True) + assert report["summary"]["error"] == 1 + assert report["summary"]["imported"] == 0 + + def test_empty_tree_all_skipped(self, profile_env, hermes_home): + root = profile_env / ".codex" + root.mkdir() + report = run_import("codex", root, hermes_home, execute=True) + assert report["summary"]["imported"] == 0 + assert report["summary"]["error"] == 0 + + +# --------------------------------------------------------------------------- +# Merge semantics & conflicts +# --------------------------------------------------------------------------- + +class TestMergeSemantics: + def test_existing_allowlist_preserved(self, claude_tree, hermes_home): + (hermes_home / "config.yaml").write_text( + yaml.safe_dump({"command_allowlist": ["docker ps"]}), encoding="utf-8") + run_import("claude-code", claude_tree, hermes_home, execute=True) + config = yaml.safe_load((hermes_home / "config.yaml").read_text()) + assert "docker ps" in config["command_allowlist"] + assert "npm run build" in config["command_allowlist"] + + def test_existing_mcp_server_conflicts_without_overwrite( + self, claude_tree, hermes_home): + (hermes_home / "config.yaml").write_text( + yaml.safe_dump({"mcp_servers": {"github": {"command": "mine"}}}), + encoding="utf-8") + report = run_import("claude-code", claude_tree, hermes_home, execute=True) + config = yaml.safe_load((hermes_home / "config.yaml").read_text()) + assert config["mcp_servers"]["github"]["command"] == "mine" + assert any( + i["status"] == "conflict" and i["source"] == "github" + for i in report["items"] + ) + + def test_overwrite_replaces_mcp_server(self, claude_tree, hermes_home): + (hermes_home / "config.yaml").write_text( + yaml.safe_dump({"mcp_servers": {"github": {"command": "mine"}}}), + encoding="utf-8") + run_import("claude-code", claude_tree, hermes_home, execute=True, + overwrite=True) + config = yaml.safe_load((hermes_home / "config.yaml").read_text()) + assert config["mcp_servers"]["github"]["command"] == "npx" + + def test_existing_skill_conflicts_without_overwrite( + self, claude_tree, hermes_home): + dest = hermes_home / "skills" / "claude-code-imports" / "deploy-helper" + dest.mkdir(parents=True) + (dest / "SKILL.md").write_text("mine\n", encoding="utf-8") + report = run_import("claude-code", claude_tree, hermes_home, execute=True) + assert (dest / "SKILL.md").read_text() == "mine\n" + assert any( + i["kind"] == "skill" and i["status"] == "conflict" + for i in report["items"] + ) + + def test_reimport_is_idempotent_for_memory(self, claude_tree, hermes_home): + run_import("claude-code", claude_tree, hermes_home, execute=True) + first = (hermes_home / "memories" / "MEMORY.md").read_text() + report = run_import("claude-code", claude_tree, hermes_home, execute=True) + assert (hermes_home / "memories" / "MEMORY.md").read_text() == first + memory_items = [i for i in report["items"] if i["kind"] == "claude-md"] + assert memory_items[0]["status"] == "skipped" + + +# --------------------------------------------------------------------------- +# CLI wiring +# --------------------------------------------------------------------------- + +class TestCliWiring: + def test_parser_builds_and_parses(self): + import argparse + from hermes_cli.subcommands.import_agent import build_import_agent_parser + + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command") + called = {} + build_import_agent_parser( + subparsers, cmd_import_agent=lambda a: called.setdefault("ok", a)) + args = parser.parse_args( + ["import-agent", "claude-code", "--dry-run", "--source", "/tmp/x"]) + assert args.agent == "claude-code" + assert args.dry_run is True + assert args.source == "/tmp/x" + args.func(args) + assert "ok" in called + + def test_rejects_unknown_agent(self): + import argparse + from hermes_cli.subcommands.import_agent import build_import_agent_parser + + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command") + build_import_agent_parser(subparsers, cmd_import_agent=lambda a: None) + with pytest.raises(SystemExit): + parser.parse_args(["import-agent", "cursor"]) + + def test_command_dry_run_via_cli_writes_nothing( + self, claude_tree, hermes_home, capsys): + """End-to-end through import_agent_command with --dry-run.""" + import types + from hermes_cli.agent_import import import_agent_command + + args = types.SimpleNamespace( + agent="claude-code", source=str(claude_tree), dry_run=True, + overwrite=False, yes=False) + import_agent_command(args) + out = capsys.readouterr().out + assert "Dry Run Results" in out + assert "command-allowlist" in out + # Baseline config.yaml/SOUL.md may be seeded by save_config() before + # the preview runs — but nothing from the IMPORT itself may land: + assert not (hermes_home / "memories" / "MEMORY.md").exists() + assert not (hermes_home / "skills" / "claude-code-imports").exists() + config_text = (hermes_home / "config.yaml").read_text(encoding="utf-8") \ + if (hermes_home / "config.yaml").exists() else "" + assert "npm run build" not in config_text + assert "github" not in config_text diff --git a/website/docs/guides/migrate-from-openclaw.md b/website/docs/guides/migrate-from-openclaw.md index 38a27e62268..669497bd1cb 100644 --- a/website/docs/guides/migrate-from-openclaw.md +++ b/website/docs/guides/migrate-from-openclaw.md @@ -8,6 +8,10 @@ description: "Complete guide to migrating your OpenClaw / Clawdbot setup to Herm `hermes claw migrate` imports your OpenClaw (or legacy Clawdbot/Moldbot) setup into Hermes. This guide covers exactly what gets migrated, the config key mappings, and what to verify after migration. +:::note +Coming from **Claude Code** or **OpenAI Codex CLI** instead? Use [`hermes import-agent`](../user-guide/import-from-other-agents.md). +::: + :::tip If your OpenClaw setup was multi-provider, `hermes setup --portal` collapses it to one OAuth — 300+ models plus the Tool Gateway in a single login. See [Nous Portal](/integrations/nous-portal). ::: diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 727dd8b5759..9084e81fae1 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -85,6 +85,7 @@ hermes [global-options] [subcommand/options] | `hermes sessions` | Browse, export, prune, rename, and delete sessions. | | `hermes insights` | Show token/cost/activity analytics. | | `hermes claw` | OpenClaw migration helpers. | +| `hermes import-agent` | Import a Claude Code (`~/.claude`) or Codex CLI (`~/.codex`) setup. | | `hermes dashboard` | Launch the web dashboard for managing config, API keys, and sessions. | | `hermes desktop` (alias `gui`) | Build and launch the native Electron desktop app. | | `hermes profile` | Manage profiles — multiple isolated Hermes instances. | @@ -1507,6 +1508,24 @@ hermes claw migrate --preset user-data --overwrite hermes claw migrate --source /home/user/old-openclaw ``` +## `hermes import-agent` + +```bash +hermes import-agent [claude-code|codex] [options] +``` + +Import a **Claude Code** (`~/.claude`) or **OpenAI Codex CLI** (`~/.codex`) setup into Hermes. Maps `CLAUDE.md`/`AGENTS.md` instructions to memory entries, `Bash(...)` permission allow/deny rules to `command_allowlist`/`approvals.deny`, MCP servers to `mcp_servers` in `config.yaml`, and skill directories into `~/.hermes/skills/`. Always previews before applying; API keys and credentials are never imported. + +| Option | Description | +| --- | --- | +| `agent` | `claude-code` or `codex` (default: auto-detect). | +| `--source ` | Custom source directory (default: `~/.claude` or `~/.codex`). | +| `--dry-run` | Preview only — write nothing. | +| `--overwrite` | Replace conflicting MCP servers / skills (default: skip). | +| `--yes`, `-y` | Skip confirmation prompts. | + +See the **[import guide](../user-guide/import-from-other-agents.md)** for the full mapping tables. + ## `hermes serve` ```bash diff --git a/website/docs/user-guide/import-from-other-agents.md b/website/docs/user-guide/import-from-other-agents.md new file mode 100644 index 00000000000..53f8d7b2304 --- /dev/null +++ b/website/docs/user-guide/import-from-other-agents.md @@ -0,0 +1,54 @@ +--- +sidebar_position: 9 +title: "Import from Other Agents" +description: "One-command import of a Claude Code (~/.claude) or OpenAI Codex CLI (~/.codex) setup into Hermes — instructions, allowlists, MCP servers, skills, and memories." +--- + +# Import from Other Agents + +`hermes import-agent` imports your existing **Claude Code** or **OpenAI Codex CLI** setup into Hermes with one command. It follows the same preview-first pattern as [`hermes claw migrate`](../guides/migrate-from-openclaw.md): you always see a per-item plan before anything is written, and `--dry-run` never touches disk. + +```bash +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 +hermes import-agent codex --source /path/to/.codex # custom location +hermes import-agent claude-code --overwrite --yes # replace conflicts, skip prompts +``` + +## What gets imported + +### Claude Code (`~/.claude`) + +| Claude Code | Hermes | +|---|---| +| `CLAUDE.md` (global instructions) | Memory entries in `~/.hermes/memories/MEMORY.md` | +| `settings.json` → `permissions.allow` (`Bash(...)` rules) | `command_allowlist` in `config.yaml` | +| `settings.json` → `permissions.deny` (`Bash(...)` rules) | `approvals.deny` in `config.yaml` | +| `mcpServers` (from `~/.claude.json` and `settings.json`) | `mcp_servers` in `config.yaml` | +| `skills//` (dirs with `SKILL.md`) | `~/.hermes/skills/claude-code-imports//` | +| `commands/*.md` (slash commands) | Skipped with a note — convert them into skills | + +Claude's `Bash(npm run test:*)` prefix rules become `npm run test*` globs. Non-`Bash` permission rules (`Read(...)`, `WebFetch`, ...) gate Claude-specific tools and are reported as unmapped rather than imported. + +### Codex CLI (`~/.codex`) + +| Codex CLI | Hermes | +|---|---| +| `AGENTS.md` (global instructions) | Memory entries in `~/.hermes/memories/MEMORY.md` | +| `config.toml` → `[mcp_servers.*]` | `mcp_servers` in `config.yaml` | +| `memories/*.md` | Memory entries in `~/.hermes/memories/MEMORY.md` | +| `skills//` (dirs with `SKILL.md`) | `~/.hermes/skills/codex-imports//` | + +## What is never imported + +**API keys and credentials.** Credential files (`~/.claude/.credentials.json`, `~/.codex/auth.json`) are never read, and MCP server environment variables or headers with secret-looking names (`*_TOKEN`, `*_API_KEY`, `Authorization`, ...) are stripped and listed in the report so you can re-add them deliberately. Run `hermes setup` to configure providers, or add secrets to `~/.hermes/.env`. + +## Behavior notes + +- **Preview first, always.** The command prints the full plan before applying; in non-interactive sessions it stops at the preview unless you pass `--yes`. +- **Merges, not replaces.** Memory entries are deduplicated against your existing `MEMORY.md`; allowlist/denylist patterns merge with what's already in `config.yaml`. +- **Conflicts are skipped by default.** An MCP server or skill that already exists in Hermes is reported as a conflict; pass `--overwrite` to replace it. +- **Malformed files don't abort the run.** A broken `settings.json` or `config.toml` becomes a per-item error in the report while everything else still imports. +- Coming from OpenClaw instead? Use [`hermes claw migrate`](../guides/migrate-from-openclaw.md).