From 3eb653c74dcb744593aa715314a9526bf0d7dce0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:17:08 -0700 Subject: [PATCH] Inspired by Energy: extend `hermes import-agent` with Gemini CLI support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//SKILL.md -> skills/gemini-imports// - 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). --- hermes_cli/agent_import.py | 169 ++++++++++++++++-- hermes_cli/subcommands/import_agent.py | 8 +- tests/hermes_cli/test_agent_import.py | 143 +++++++++++++++ website/docs/reference/cli-commands.md | 10 +- .../user-guide/import-from-other-agents.md | 21 ++- 5 files changed, 323 insertions(+), 28 deletions(-) diff --git a/hermes_cli/agent_import.py b/hermes_cli/agent_import.py index 7439133a807e1..2a6a5cf2ab33a 100644 --- a/hermes_cli/agent_import.py +++ b/hermes_cli/agent_import.py @@ -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//SKILL.md → HERMES_HOME/skills/codex-imports// +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//SKILL.md → HERMES_HOME/skills/gemini-imports// + 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.*)\)$" +) + + +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] diff --git a/hermes_cli/subcommands/import_agent.py b/hermes_cli/subcommands/import_agent.py index c20d10b053e2e..249be5c285277 100644 --- a/hermes_cli/subcommands/import_agent.py +++ b/hermes_cli/subcommands/import_agent.py @@ -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", diff --git a/tests/hermes_cli/test_agent_import.py b/tests/hermes_cli/test_agent_import.py index d00ae59f3ad37..fdb79dfaf8d85 100644 --- a/tests/hermes_cli/test_agent_import.py +++ b/tests/hermes_cli/test_agent_import.py @@ -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 # --------------------------------------------------------------------------- diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index c1d63a59cd4bd..1c9b4ced13ed6 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -89,7 +89,7 @@ hermes [global-options] [subcommand/options] | `hermes sessions` | Browse, export, prune, rename, and delete sessions. | | `hermes insights` | Show token/cost/activity analytics. | | `hermes claw` | OpenClaw migration helpers. | -| `hermes import-agent` | Import a Claude Code (`~/.claude`) or Codex CLI (`~/.codex`) setup. | +| `hermes 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 ` | Custom source directory (default: `~/.claude` or `~/.codex`). | +| `agent` | `claude-code`, `codex`, or `gemini` (default: auto-detect). | +| `--source ` | 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. | diff --git a/website/docs/user-guide/import-from-other-agents.md b/website/docs/user-guide/import-from-other-agents.md index 53f8d7b2304ec..db57941253155 100644 --- a/website/docs/user-guide/import-from-other-agents.md +++ b/website/docs/user-guide/import-from-other-agents.md @@ -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//` (dirs with `SKILL.md`) | `~/.hermes/skills/codex-imports//` | +### 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//` (dirs with `SKILL.md`) | `~/.hermes/skills/gemini-imports//` | +| `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