Inspired by Energy: extend `hermes import-agent` with Gemini CLI support
Energy's launch messaging leads with "import memories & skills — no fresh start" as a core onboarding move. Hermes already imports Claude Code and Codex setups; this widens the same no-fresh-start funnel to the third major CLI agent, Google Gemini CLI (~/.gemini): - GEMINI.md -> memory entries in memories/MEMORY.md - settings.json tools.allowed (run_shell_command(...)/ShellTool(...) rules, plus the legacy flat allowedTools key) -> config.yaml command_allowlist - settings.json mcpServers -> config.yaml mcp_servers, honoring Gemini's httpUrl > url transport precedence; the per-server trust:true flag is deliberately dropped so Hermes approval settings stay untouched - skills/<name>/SKILL.md -> skills/gemini-imports/<name>/ - extensions/ reported skipped with guidance (they bundle their own MCP servers/context) Secrets rule unchanged: oauth_creds.json never read, secret-looking env vars and Authorization headers stripped and reported. Refactors the Claude allowlist merge into a shared _merge_command_allowlist so both mappers use one merge path. 10 new tests (60 total in the file).
This commit is contained in:
parent
b3aa561faf
commit
3eb653c74d
|
|
@ -1,9 +1,10 @@
|
|||
"""hermes import-agent — import Claude Code / Codex CLI setups into Hermes.
|
||||
"""hermes import-agent — import Claude Code / Codex / Gemini CLI setups into Hermes.
|
||||
|
||||
Usage:
|
||||
hermes import-agent # auto-detect ~/.claude or ~/.codex
|
||||
hermes import-agent # auto-detect ~/.claude, ~/.codex or ~/.gemini
|
||||
hermes import-agent claude-code # import from ~/.claude
|
||||
hermes import-agent codex # import from ~/.codex
|
||||
hermes import-agent gemini # import from ~/.gemini
|
||||
hermes import-agent claude-code --dry-run # preview only, no changes
|
||||
hermes import-agent codex --source /path/to/.codex
|
||||
|
||||
|
|
@ -30,6 +31,20 @@ codex (~/.codex):
|
|||
memories/*.md → memory entries in HERMES_HOME/memories/MEMORY.md
|
||||
skills/<name>/SKILL.md → HERMES_HOME/skills/codex-imports/<name>/
|
||||
|
||||
gemini (~/.gemini, Google Gemini CLI):
|
||||
GEMINI.md → memory entries in HERMES_HOME/memories/MEMORY.md
|
||||
settings.json tools.allowed → config.yaml command_allowlist
|
||||
(run_shell_command(...) / ShellTool(...) rules; legacy flat
|
||||
``allowedTools`` is read too)
|
||||
settings.json mcpServers → config.yaml mcp_servers
|
||||
(``httpUrl`` takes precedence over ``url``, matching Gemini's own
|
||||
transport precedence; ``trust: true`` is deliberately dropped —
|
||||
Hermes approval settings stay untouched)
|
||||
skills/<name>/SKILL.md → HERMES_HOME/skills/gemini-imports/<name>/
|
||||
extensions/ → skipped with a note (extensions bundle
|
||||
their own MCP servers/context; reinstall the Hermes-side equivalents
|
||||
deliberately)
|
||||
|
||||
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
|
||||
|
|
@ -59,16 +74,18 @@ ENTRY_DELIMITER = "\n§\n"
|
|||
# default memory limit).
|
||||
MEMORY_CHAR_LIMIT = 20_000
|
||||
|
||||
SUPPORTED_AGENTS = ("claude-code", "codex")
|
||||
SUPPORTED_AGENTS = ("claude-code", "codex", "gemini")
|
||||
|
||||
_AGENT_DEFAULT_DIRS = {
|
||||
"claude-code": ".claude",
|
||||
"codex": ".codex",
|
||||
"gemini": ".gemini",
|
||||
}
|
||||
|
||||
_SKILL_CATEGORY = {
|
||||
"claude-code": "claude-code-imports",
|
||||
"codex": "codex-imports",
|
||||
"gemini": "gemini-imports",
|
||||
}
|
||||
|
||||
# Env var names that look like credentials — never copied into config.yaml.
|
||||
|
|
@ -351,6 +368,37 @@ def claude_rule_to_command_pattern(rule: str) -> Optional[str]:
|
|||
return inner
|
||||
|
||||
|
||||
_GEMINI_SHELL_RULE_RE = re.compile(
|
||||
r"^(?:run_shell_command|ShellTool)\((?P<inner>.*)\)$"
|
||||
)
|
||||
|
||||
|
||||
def gemini_rule_to_command_pattern(rule: str) -> Optional[str]:
|
||||
"""Convert a Gemini CLI allowed-tool rule into a Hermes command glob.
|
||||
|
||||
Gemini's ``tools.allowed`` (and the legacy flat ``allowedTools``) lists
|
||||
tool names that bypass the confirmation dialog. Shell rules look like
|
||||
``run_shell_command(git status)`` or ``ShellTool(git status)`` and match
|
||||
by command prefix, so they map to a trailing-glob Hermes pattern:
|
||||
|
||||
``run_shell_command(git status)`` → ``git status*``
|
||||
``ShellTool(npm test)`` → ``npm test*``
|
||||
``run_shell_command`` → None (blanket rule, too broad)
|
||||
Non-shell tool names (``write_file``, ``WebFetch``, ...) → None: they
|
||||
gate Gemini-specific tools with no command-allowlist equivalent.
|
||||
"""
|
||||
rule = (rule or "").strip()
|
||||
m = _GEMINI_SHELL_RULE_RE.match(rule)
|
||||
if not m:
|
||||
return None
|
||||
inner = m.group("inner").strip()
|
||||
if not inner:
|
||||
return None
|
||||
if not inner.endswith("*"):
|
||||
inner = inner + "*"
|
||||
return inner
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -469,6 +517,8 @@ class AgentImporter:
|
|||
return self.build_report()
|
||||
if self.agent == "claude-code":
|
||||
self._run_claude_code()
|
||||
elif self.agent == "gemini":
|
||||
self._run_gemini()
|
||||
else:
|
||||
self._run_codex()
|
||||
return self.build_report()
|
||||
|
|
@ -497,6 +547,24 @@ class AgentImporter:
|
|||
self.import_memories_dir(self.source_root / "memories")
|
||||
self.import_skills(self.source_root / "skills")
|
||||
|
||||
def _run_gemini(self) -> None:
|
||||
# Inspired by the Energy scout finding "import memories & skills —
|
||||
# no fresh start": widen import-agent to the third major CLI agent.
|
||||
settings = self._load_gemini_settings()
|
||||
self.import_context_file(self.source_root / "GEMINI.md", kind="gemini-md")
|
||||
self.import_gemini_allowlist(settings)
|
||||
mcp = settings.get("mcpServers")
|
||||
self.import_mcp_servers(mcp if isinstance(mcp, dict) else {},
|
||||
kind="mcp-servers")
|
||||
self.import_skills(self.source_root / "skills")
|
||||
extensions_dir = self.source_root / "extensions"
|
||||
if extensions_dir.is_dir() and any(extensions_dir.iterdir()):
|
||||
self.record(
|
||||
"extensions", extensions_dir, None, "skipped",
|
||||
"Gemini extensions bundle their own MCP servers and context — "
|
||||
"re-add the servers you need to Hermes config.yaml deliberately",
|
||||
)
|
||||
|
||||
# -- parsers (fail soft: bad files become per-item error records) -------
|
||||
|
||||
def _load_claude_settings(self) -> Dict[str, Any]:
|
||||
|
|
@ -551,6 +619,24 @@ class AgentImporter:
|
|||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
def _load_gemini_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
|
||||
|
||||
# -- mappers -------------------------------------------------------------
|
||||
|
||||
def import_context_file(self, source: Path, kind: str) -> None:
|
||||
|
|
@ -635,11 +721,11 @@ class AgentImporter:
|
|||
|
||||
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",
|
||||
self.record("command-allowlist", None,
|
||||
self.target_root / "config.yaml", "skipped",
|
||||
"No permissions.allow rules found")
|
||||
return
|
||||
|
||||
|
|
@ -653,15 +739,63 @@ class AgentImporter:
|
|||
patterns.append(pattern)
|
||||
else:
|
||||
skipped_rules.append(rule)
|
||||
self._merge_command_allowlist(
|
||||
source_label="settings.json permissions.allow",
|
||||
patterns=patterns,
|
||||
skipped_rules=skipped_rules,
|
||||
empty_reason="No Bash(...) allow rules to import",
|
||||
)
|
||||
|
||||
def import_gemini_allowlist(self, settings: Dict[str, Any]) -> None:
|
||||
"""settings.json tools.allowed / allowedTools → config.yaml command_allowlist.
|
||||
|
||||
Gemini CLI stores auto-approved tools under the nested
|
||||
``tools.allowed`` key (current schema) or the legacy flat
|
||||
``allowedTools`` key; both are read.
|
||||
"""
|
||||
tools = settings.get("tools")
|
||||
allowed = tools.get("allowed") if isinstance(tools, dict) else None
|
||||
rules: List[Any] = list(allowed) if isinstance(allowed, list) else []
|
||||
legacy = settings.get("allowedTools")
|
||||
if isinstance(legacy, list):
|
||||
rules.extend(legacy)
|
||||
if not rules:
|
||||
self.record("command-allowlist", None,
|
||||
self.target_root / "config.yaml", "skipped",
|
||||
"No tools.allowed rules found")
|
||||
return
|
||||
|
||||
patterns: List[str] = []
|
||||
skipped_rules: List[str] = []
|
||||
for rule in rules:
|
||||
if not isinstance(rule, str):
|
||||
continue
|
||||
pattern = gemini_rule_to_command_pattern(rule)
|
||||
if pattern:
|
||||
patterns.append(pattern)
|
||||
else:
|
||||
skipped_rules.append(rule)
|
||||
self._merge_command_allowlist(
|
||||
source_label="settings.json tools.allowed",
|
||||
patterns=patterns,
|
||||
skipped_rules=skipped_rules,
|
||||
empty_reason="No run_shell_command(...) allow rules to import",
|
||||
)
|
||||
|
||||
def _merge_command_allowlist(self, source_label: str,
|
||||
patterns: List[str],
|
||||
skipped_rules: List[str],
|
||||
empty_reason: str) -> None:
|
||||
"""Merge command patterns into config.yaml command_allowlist."""
|
||||
destination = self.target_root / "config.yaml"
|
||||
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)
|
||||
empty_reason, unmapped_rules=skipped_rules)
|
||||
return
|
||||
|
||||
config = self.load_target_config(
|
||||
"command-allowlist", "settings.json permissions.allow", destination)
|
||||
"command-allowlist", source_label, destination)
|
||||
if config is None:
|
||||
return
|
||||
current = config.get("command_allowlist", [])
|
||||
|
|
@ -670,7 +804,7 @@ class AgentImporter:
|
|||
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",
|
||||
self.record("command-allowlist", source_label,
|
||||
destination, "skipped", "All patterns already present")
|
||||
return
|
||||
details: Dict[str, Any] = {"added_patterns": added}
|
||||
|
|
@ -679,10 +813,10 @@ class AgentImporter:
|
|||
if self.execute:
|
||||
config["command_allowlist"] = merged
|
||||
dump_yaml_file(destination, config)
|
||||
self.record("command-allowlist", "settings.json permissions.allow",
|
||||
self.record("command-allowlist", source_label,
|
||||
destination, "imported", **details)
|
||||
else:
|
||||
self.record("command-allowlist", "settings.json permissions.allow",
|
||||
self.record("command-allowlist", source_label,
|
||||
destination, "imported", "Would merge patterns", **details)
|
||||
|
||||
def import_permission_denylist(self, settings: Dict[str, Any]) -> None:
|
||||
|
|
@ -774,8 +908,12 @@ class AgentImporter:
|
|||
)
|
||||
if srv.get("cwd"):
|
||||
hermes_srv["cwd"] = srv["cwd"]
|
||||
if srv.get("url"):
|
||||
hermes_srv["url"] = srv["url"]
|
||||
# Gemini CLI uses ``httpUrl`` for streamable-HTTP servers and
|
||||
# gives it precedence over ``url`` (SSE); Hermes takes either
|
||||
# transport through its single ``url`` key.
|
||||
remote_url = srv.get("httpUrl") or srv.get("url")
|
||||
if remote_url:
|
||||
hermes_srv["url"] = remote_url
|
||||
headers = srv.get("headers")
|
||||
if isinstance(headers, dict):
|
||||
kept_headers = {
|
||||
|
|
@ -866,13 +1004,14 @@ def import_agent_command(args) -> None:
|
|||
detected = detect_agents()
|
||||
if not detected:
|
||||
print()
|
||||
print_error("No supported agent setup found (~/.claude or ~/.codex).")
|
||||
print_error("No supported agent setup found "
|
||||
"(~/.claude, ~/.codex or ~/.gemini).")
|
||||
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")
|
||||
print_info("Pick one: hermes import-agent claude-code | codex | gemini")
|
||||
return
|
||||
agent = detected[0]
|
||||
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ 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",
|
||||
help="Import a Claude Code, Codex CLI, or Gemini 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 "
|
||||
"Maps CLAUDE.md/AGENTS.md/GEMINI.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."
|
||||
|
|
@ -26,8 +26,8 @@ def build_import_agent_parser(subparsers, *, cmd_import_agent: Callable) -> None
|
|||
parser.add_argument(
|
||||
"agent",
|
||||
nargs="?",
|
||||
choices=["claude-code", "codex"],
|
||||
help="Which agent to import from (default: auto-detect ~/.claude or ~/.codex)",
|
||||
choices=["claude-code", "codex", "gemini"],
|
||||
help="Which agent to import from (default: auto-detect ~/.claude, ~/.codex, or ~/.gemini)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--source",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from hermes_cli.agent_import import (
|
|||
claude_rule_to_command_pattern,
|
||||
detect_agents,
|
||||
extract_markdown_entries,
|
||||
gemini_rule_to_command_pattern,
|
||||
is_secret_key,
|
||||
parse_existing_memory_entries,
|
||||
sanitize_mcp_env,
|
||||
|
|
@ -160,6 +161,63 @@ def codex_tree(profile_env):
|
|||
return root
|
||||
|
||||
|
||||
GEMINI_MD = """# Gemini instructions
|
||||
|
||||
- Use conventional commits
|
||||
- Run tests before pushing
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def gemini_tree(profile_env):
|
||||
"""Build a fake ~/.gemini tree (Google Gemini CLI)."""
|
||||
root = profile_env / ".gemini"
|
||||
root.mkdir()
|
||||
(root / "GEMINI.md").write_text(GEMINI_MD, encoding="utf-8")
|
||||
(root / "settings.json").write_text(json.dumps({
|
||||
"tools": {
|
||||
"allowed": [
|
||||
"run_shell_command(git status)",
|
||||
"ShellTool(npm test)",
|
||||
"run_shell_command", # blanket → unmapped
|
||||
"write_file", # non-shell → unmapped
|
||||
],
|
||||
},
|
||||
"allowedTools": ["run_shell_command(make lint)"], # legacy flat key
|
||||
"mcpServers": {
|
||||
"docs": {
|
||||
"command": "uvx",
|
||||
"args": ["docs-mcp"],
|
||||
"env": {
|
||||
"DOCS_API_KEY": "secret-value",
|
||||
"DOCS_REGION": "eu",
|
||||
},
|
||||
"trust": True, # must NOT leak into Hermes config
|
||||
},
|
||||
"streaming": {
|
||||
"httpUrl": "https://mcp.example.com/mcp",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"headers": {
|
||||
"Authorization": "Bearer abc123",
|
||||
"X-Region": "us-east",
|
||||
},
|
||||
},
|
||||
},
|
||||
}), encoding="utf-8")
|
||||
# OAuth credential file that must never be read/imported
|
||||
(root / "oauth_creds.json").write_text(
|
||||
json.dumps({"refresh_token": "SUPERSECRET"}), encoding="utf-8")
|
||||
skill = root / "skills" / "gcp-deploy"
|
||||
skill.mkdir(parents=True)
|
||||
(skill / "SKILL.md").write_text(
|
||||
"---\nname: gcp-deploy\n---\n\nDeploy to GCP.\n", encoding="utf-8")
|
||||
# extensions dir → reported skipped
|
||||
ext = root / "extensions" / "cloud-run"
|
||||
ext.mkdir(parents=True)
|
||||
(ext / "gemini-extension.json").write_text("{}", encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
def snapshot_tree(root: Path) -> dict:
|
||||
"""Map of relative-path -> bytes for every file under root."""
|
||||
return {
|
||||
|
|
@ -186,6 +244,8 @@ class TestDetection:
|
|||
def test_detects_claude_and_codex(self, claude_tree, codex_tree):
|
||||
assert detect_agents() == ["claude-code", "codex"]
|
||||
|
||||
def test_detects_gemini(self, gemini_tree):
|
||||
assert detect_agents() == ["gemini"]
|
||||
|
||||
def test_unsupported_agent_raises(self, hermes_home, tmp_path):
|
||||
with pytest.raises(ValueError):
|
||||
|
|
@ -201,6 +261,18 @@ class TestRuleMapping:
|
|||
assert claude_rule_to_command_pattern("Read(~/.zshrc)") is None
|
||||
assert claude_rule_to_command_pattern("WebFetch") is None
|
||||
|
||||
def test_gemini_shell_rules(self):
|
||||
assert gemini_rule_to_command_pattern(
|
||||
"run_shell_command(git status)") == "git status*"
|
||||
assert gemini_rule_to_command_pattern(
|
||||
"ShellTool(npm test)") == "npm test*"
|
||||
|
||||
def test_gemini_blanket_and_non_shell_rules_are_none(self):
|
||||
assert gemini_rule_to_command_pattern("run_shell_command") is None
|
||||
assert gemini_rule_to_command_pattern("ShellTool") is None
|
||||
assert gemini_rule_to_command_pattern("write_file") is None
|
||||
assert gemini_rule_to_command_pattern("WebFetch(example.com)") is None
|
||||
|
||||
|
||||
class TestSecretDetection:
|
||||
@pytest.mark.parametrize("key", [
|
||||
|
|
@ -298,6 +370,77 @@ class TestCodexImport:
|
|||
assert (hermes_home / "skills" / "codex-imports" / "db-migrate" / "SKILL.md").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gemini CLI real run
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGeminiImport:
|
||||
@pytest.fixture()
|
||||
def report(self, gemini_tree, hermes_home):
|
||||
return run_import("gemini", gemini_tree, hermes_home, execute=True)
|
||||
|
||||
def test_gemini_md_lands_in_memory(self, report, hermes_home):
|
||||
memory = (hermes_home / "memories" / "MEMORY.md").read_text()
|
||||
assert "conventional commits" in memory
|
||||
assert "tests before pushing" in memory
|
||||
|
||||
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 "git status*" in allow
|
||||
assert "npm test*" in allow
|
||||
assert "make lint*" in allow # legacy flat allowedTools
|
||||
# blanket and non-shell rules must not leak in
|
||||
assert "run_shell_command" not in allow
|
||||
assert "write_file" not in allow
|
||||
|
||||
def test_unmapped_rules_reported(self, report):
|
||||
item = next(i for i in report["items"]
|
||||
if i["kind"] == "command-allowlist")
|
||||
assert "run_shell_command" in item.get("unmapped_rules", [])
|
||||
assert "write_file" in item.get("unmapped_rules", [])
|
||||
|
||||
def test_mcp_servers_land_in_config_yaml(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"]
|
||||
assert docs["env"] == {"DOCS_REGION": "eu"}
|
||||
# Gemini's trust flag must never reach Hermes config
|
||||
assert "trust" not in docs
|
||||
|
||||
def test_http_url_takes_precedence_over_sse_url(self, report, hermes_home):
|
||||
config = yaml.safe_load((hermes_home / "config.yaml").read_text())
|
||||
streaming = config["mcp_servers"]["streaming"]
|
||||
assert streaming["url"] == "https://mcp.example.com/mcp"
|
||||
# Authorization header stripped, plain header kept
|
||||
assert streaming["headers"] == {"X-Region": "us-east"}
|
||||
|
||||
def test_skill_copied(self, report, hermes_home):
|
||||
assert (hermes_home / "skills" / "gemini-imports" / "gcp-deploy"
|
||||
/ "SKILL.md").exists()
|
||||
|
||||
def test_extensions_reported_skipped(self, report):
|
||||
items = {i["kind"]: i for i in report["items"]}
|
||||
assert items["extensions"]["status"] == "skipped"
|
||||
|
||||
def test_no_secret_values_anywhere(self, gemini_tree, hermes_home):
|
||||
run_import("gemini", gemini_tree, hermes_home, execute=True)
|
||||
for p in hermes_home.rglob("*"):
|
||||
if p.is_file():
|
||||
content = p.read_text(errors="replace")
|
||||
assert "secret-value" not in content
|
||||
assert "SUPERSECRET" not in content
|
||||
assert "Bearer abc123" not in content
|
||||
|
||||
def test_dry_run_writes_nothing(self, gemini_tree, hermes_home):
|
||||
before = snapshot_tree(hermes_home)
|
||||
report = run_import("gemini", gemini_tree, hermes_home, execute=False)
|
||||
assert snapshot_tree(hermes_home) == before
|
||||
assert report["dry_run"] is True
|
||||
assert report["summary"]["imported"] > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Secrets are never copied
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -89,7 +89,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 import-agent` | Import a Claude Code (`~/.claude`), Codex CLI (`~/.codex`), or Gemini CLI (`~/.gemini`) setup. |
|
||||
| `hermes dashboard` | Launch the web dashboard for managing config, API keys, and sessions. |
|
||||
| `hermes serve` | Start the Hermes backend server (headless; powers the desktop app and remote backends). |
|
||||
| `hermes desktop` (alias `gui`) | Build and launch the native Electron desktop app. |
|
||||
|
|
@ -1533,15 +1533,15 @@ hermes claw migrate --source /home/user/old-openclaw
|
|||
## `hermes import-agent`
|
||||
|
||||
```bash
|
||||
hermes import-agent [claude-code|codex] [options]
|
||||
hermes import-agent [claude-code|codex|gemini] [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.
|
||||
Import a **Claude Code** (`~/.claude`), **OpenAI Codex CLI** (`~/.codex`), or **Google Gemini CLI** (`~/.gemini`) setup into Hermes. Maps `CLAUDE.md`/`AGENTS.md`/`GEMINI.md` instructions to memory entries, permission allow rules (`Bash(...)` / `run_shell_command(...)`) to `command_allowlist`, 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`). |
|
||||
| `agent` | `claude-code`, `codex`, or `gemini` (default: auto-detect). |
|
||||
| `--source <path>` | Custom source directory (default: `~/.claude`, `~/.codex`, or `~/.gemini`). |
|
||||
| `--dry-run` | Preview only — write nothing. |
|
||||
| `--overwrite` | Replace conflicting MCP servers / skills (default: skip). |
|
||||
| `--yes`, `-y` | Skip confirmation prompts. |
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
---
|
||||
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."
|
||||
description: "One-command import of a Claude Code (~/.claude), OpenAI Codex CLI (~/.codex), or Google Gemini CLI (~/.gemini) 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.
|
||||
`hermes import-agent` imports your existing **Claude Code**, **OpenAI Codex CLI**, or **Google Gemini 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 # auto-detect ~/.claude, ~/.codex, or ~/.gemini
|
||||
hermes import-agent claude-code # import from ~/.claude
|
||||
hermes import-agent codex # import from ~/.codex
|
||||
hermes import-agent gemini # import from ~/.gemini
|
||||
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
|
||||
|
|
@ -41,9 +42,21 @@ Claude's `Bash(npm run test:*)` prefix rules become `npm run test*` globs. Non-`
|
|||
| `memories/*.md` | Memory entries in `~/.hermes/memories/MEMORY.md` |
|
||||
| `skills/<name>/` (dirs with `SKILL.md`) | `~/.hermes/skills/codex-imports/<name>/` |
|
||||
|
||||
### Gemini CLI (`~/.gemini`)
|
||||
|
||||
| Gemini CLI | Hermes |
|
||||
|---|---|
|
||||
| `GEMINI.md` (global instructions) | Memory entries in `~/.hermes/memories/MEMORY.md` |
|
||||
| `settings.json` → `tools.allowed` (`run_shell_command(...)` / `ShellTool(...)` rules; legacy flat `allowedTools` too) | `command_allowlist` in `config.yaml` |
|
||||
| `settings.json` → `mcpServers` | `mcp_servers` in `config.yaml` |
|
||||
| `skills/<name>/` (dirs with `SKILL.md`) | `~/.hermes/skills/gemini-imports/<name>/` |
|
||||
| `extensions/` | Skipped with a note — extensions bundle their own MCP servers/context; re-add what you need deliberately |
|
||||
|
||||
Gemini's shell rules match by command prefix, so `run_shell_command(git status)` becomes the `git status*` glob. Non-shell tool names (`write_file`, ...) are reported as unmapped. For MCP servers, `httpUrl` takes precedence over `url` (matching Gemini's own transport precedence), and the per-server `trust: true` flag is deliberately dropped — Hermes approval settings stay untouched.
|
||||
|
||||
## 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`.
|
||||
**API keys and credentials.** Credential files (`~/.claude/.credentials.json`, `~/.codex/auth.json`, `~/.gemini/oauth_creds.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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue