mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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.
This commit is contained in:
parent
62e8842d07
commit
24c3c27ba8
6 changed files with 1572 additions and 0 deletions
905
hermes_cli/agent_import.py
Normal file
905
hermes_cli/agent_import.py
Normal file
|
|
@ -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/<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)}")
|
||||
49
hermes_cli/subcommands/import_agent.py
Normal file
49
hermes_cli/subcommands/import_agent.py
Normal file
|
|
@ -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)
|
||||
541
tests/hermes_cli/test_agent_import.py
Normal file
541
tests/hermes_cli/test_agent_import.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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).
|
||||
:::
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ hermes [global-options] <command> [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 <path>` | 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
|
||||
|
|
|
|||
54
website/docs/user-guide/import-from-other-agents.md
Normal file
54
website/docs/user-guide/import-from-other-agents.md
Normal file
|
|
@ -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/<name>/` (dirs with `SKILL.md`) | `~/.hermes/skills/claude-code-imports/<name>/` |
|
||||
| `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/<name>/` (dirs with `SKILL.md`) | `~/.hermes/skills/codex-imports/<name>/` |
|
||||
|
||||
## 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).
|
||||
Loading…
Add table
Add a link
Reference in a new issue