Instructions to use Wellz1000/hermes-2 with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- HERMES
How to use Wellz1000/hermes-2 with HERMES:
# No code snippets available yet for this library. # To use this model, check the repository files and the library's documentation. # Want to help? PRs adding snippets are welcome at: # https://github.com/huggingface/huggingface.js
- Notebooks
- Google Colab
- Kaggle
Download hermes_cli/codex_runtime_plugin_migration.py from Wellz1000/hermes-2: direct link, hf CLI and curl.
- Browser
- Download file 32.7 kB
-
https://huggingface.co/Wellz1000/hermes-2/resolve/main/hermes_cli/codex_runtime_plugin_migration.py
- Command line
-
hf download hf://Wellz1000/hermes-2/hermes_cli/codex_runtime_plugin_migration.py
-
curl -L -o codex_runtime_plugin_migration.py https://huggingface.co/Wellz1000/hermes-2/resolve/main/hermes_cli/codex_runtime_plugin_migration.py
32.7 kB
| """Migrate Hermes' MCP server config and Codex's installed curated plugins | |
| to the format Codex expects in ~/.codex/config.toml. | |
| When the user enables the codex_app_server runtime, the codex subprocess | |
| runs its own MCP client and its own plugin runtime (Linear, Atlassian, | |
| Asana, plus per-account ChatGPT apps via app/list). For both of those to | |
| be useful, the user's choices need to be visible to codex too. This | |
| module: | |
| 1. Reads Hermes' YAML and writes equivalent [mcp_servers.<name>] | |
| entries to ~/.codex/config.toml. | |
| 2. Queries codex's `plugin/list` for the openai-curated marketplace | |
| and writes [plugins."<name>@<marketplace>"] entries for any plugin | |
| the user has installed=true on their codex CLI. (This is what | |
| OpenClaw calls "migrate native codex plugins" — the YouTube-video- | |
| worthy bit Pash highlighted: Canva, GitHub, Calendar, Gmail | |
| pre-configured.) | |
| 3. Writes a [permissions] default profile so users on this runtime | |
| don't get an approval prompt on every write attempt. | |
| What translates (MCP servers): | |
| Hermes mcp_servers.<n>.command/args/env → codex stdio transport | |
| Hermes mcp_servers.<n>.url/headers → codex streamable_http transport | |
| Hermes mcp_servers.<n>.timeout → codex tool_timeout_sec | |
| Hermes mcp_servers.<n>.connect_timeout → codex startup_timeout_sec | |
| What does NOT translate (warned + skipped): | |
| Hermes-specific keys (sampling, etc.) — codex's MCP client has no | |
| equivalent. Listed in the per-server skipped[] field of the report. | |
| What's NOT migrated (intentional): | |
| AGENTS.md — codex respects this file natively in its cwd. Hermes' own | |
| AGENTS.md (project-level) is already in the worktree, so codex picks | |
| it up without translation. No code needed. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import os | |
| from dataclasses import dataclass, field | |
| from pathlib import Path | |
| from typing import Any, Optional | |
| logger = logging.getLogger(__name__) | |
| # Marker comments wrapping the managed section so re-runs can detect | |
| # what's ours and what's user-edited. Both must appear or strip is a no-op. | |
| MIGRATION_MARKER = ( | |
| "# managed by hermes-agent — `hermes codex-runtime migrate` regenerates this section" | |
| ) | |
| MIGRATION_END_MARKER = ( | |
| "# end hermes-agent managed section" | |
| ) | |
| class MigrationReport: | |
| """Outcome of a migration pass.""" | |
| target_path: Optional[Path] = None | |
| migrated: list[str] = field(default_factory=list) | |
| skipped_keys_per_server: dict[str, list[str]] = field(default_factory=dict) | |
| migrated_plugins: list[str] = field(default_factory=list) | |
| plugin_query_error: Optional[str] = None | |
| wrote_permissions_default: Optional[str] = None | |
| errors: list[str] = field(default_factory=list) | |
| written: bool = False | |
| dry_run: bool = False | |
| def summary(self) -> str: | |
| lines = [] | |
| if self.dry_run: | |
| lines.append(f"(dry run) Would write {self.target_path}") | |
| elif self.written: | |
| lines.append(f"Wrote {self.target_path}") | |
| if self.migrated: | |
| lines.append(f"Migrated {len(self.migrated)} MCP server(s):") | |
| for name in self.migrated: | |
| skipped = self.skipped_keys_per_server.get(name, []) | |
| note = ( | |
| f" (skipped: {', '.join(skipped)})" if skipped else "" | |
| ) | |
| lines.append(f" - {name}{note}") | |
| else: | |
| lines.append("No MCP servers found in Hermes config.") | |
| if self.migrated_plugins: | |
| lines.append( | |
| f"Migrated {len(self.migrated_plugins)} native Codex plugin(s):" | |
| ) | |
| for name in self.migrated_plugins: | |
| lines.append(f" - {name}") | |
| elif self.plugin_query_error: | |
| lines.append(f"Codex plugin discovery skipped: {self.plugin_query_error}") | |
| if self.wrote_permissions_default: | |
| lines.append( | |
| f"Wrote default_permissions = " | |
| f"{self.wrote_permissions_default!r}" | |
| ) | |
| for err in self.errors: | |
| lines.append(f"⚠ {err}") | |
| return "\n".join(lines) | |
| # Hermes keys that codex's MCP schema doesn't support — dropped during | |
| # migration with a warning. Anything not on the keep list AND not the | |
| # transport keys is added to skipped. | |
| _KNOWN_HERMES_KEYS = { | |
| # transport — stdio | |
| "command", "args", "env", "cwd", | |
| # transport — http | |
| "url", "headers", "transport", | |
| # timeouts | |
| "timeout", "connect_timeout", | |
| # general | |
| "enabled", "description", | |
| } | |
| # Subset that have a direct codex equivalent. | |
| _KEYS_DROPPED_WITH_WARNING = { | |
| # Hermes' sampling subsection — codex MCP has no equivalent | |
| "sampling", | |
| } | |
| def _translate_one_server( | |
| name: str, hermes_cfg: dict | |
| ) -> tuple[Optional[dict], list[str]]: | |
| """Translate one Hermes MCP server config to the codex inline-table dict | |
| representation. Returns (codex_entry, skipped_keys). | |
| codex_entry is a dict ready for TOML serialization, or None when the | |
| server can't be translated (e.g. neither command nor url present).""" | |
| if not isinstance(hermes_cfg, dict): | |
| return None, [] | |
| skipped: list[str] = [] | |
| out: dict[str, Any] = {} | |
| has_command = bool(hermes_cfg.get("command")) | |
| has_url = bool(hermes_cfg.get("url")) | |
| if has_command and has_url: | |
| skipped.append("url (both command and url set; preferring stdio)") | |
| has_url = False | |
| if has_command: | |
| # Stdio transport | |
| out["command"] = str(hermes_cfg["command"]) | |
| args = hermes_cfg.get("args") or [] | |
| if args: | |
| out["args"] = [str(a) for a in args] | |
| env = hermes_cfg.get("env") or {} | |
| if env: | |
| # Codex expects string values | |
| out["env"] = {str(k): str(v) for k, v in env.items()} | |
| cwd = hermes_cfg.get("cwd") | |
| if cwd: | |
| out["cwd"] = str(cwd) | |
| elif has_url: | |
| # streamable_http transport (codex covers both http and SSE here) | |
| out["url"] = str(hermes_cfg["url"]) | |
| headers = hermes_cfg.get("headers") or {} | |
| if headers: | |
| out["http_headers"] = {str(k): str(v) for k, v in headers.items()} | |
| # Hermes' transport: sse hint is informational; codex auto-negotiates | |
| if hermes_cfg.get("transport") == "sse": | |
| skipped.append("transport=sse (codex auto-negotiates)") | |
| else: | |
| return None, ["no command or url field"] | |
| # Timeouts | |
| if "timeout" in hermes_cfg: | |
| try: | |
| out["tool_timeout_sec"] = float(hermes_cfg["timeout"]) | |
| except (TypeError, ValueError): | |
| skipped.append("timeout (not numeric)") | |
| if "connect_timeout" in hermes_cfg: | |
| try: | |
| out["startup_timeout_sec"] = float(hermes_cfg["connect_timeout"]) | |
| except (TypeError, ValueError): | |
| skipped.append("connect_timeout (not numeric)") | |
| # Enabled flag (codex defaults to true so we only emit when explicitly false) | |
| if hermes_cfg.get("enabled") is False: | |
| out["enabled"] = False | |
| # Detect keys we explicitly drop with warning | |
| for key in hermes_cfg: | |
| if key in _KEYS_DROPPED_WITH_WARNING: | |
| skipped.append(f"{key} (no codex equivalent)") | |
| elif key not in _KNOWN_HERMES_KEYS: | |
| skipped.append(f"{key} (unknown Hermes key)") | |
| return out, skipped | |
| def _format_toml_value(value: Any) -> str: | |
| """Minimal TOML value formatter for the value types we emit. | |
| We only emit strings, numbers, booleans, and tables of those — no nested | |
| arrays of tables. This covers everything codex's MCP schema accepts.""" | |
| if isinstance(value, bool): | |
| return "true" if value else "false" | |
| if isinstance(value, (int, float)): | |
| return repr(value) | |
| if isinstance(value, str): | |
| # Escape per TOML basic-string rules. Order matters: backslash | |
| # first so the other escapes don't get re-escaped. | |
| # Control characters (newline, tab, etc.) must use \-escapes | |
| # because TOML basic strings don't allow literal control chars | |
| # — passing them through would produce invalid TOML that codex | |
| # would refuse to load. Paths usually don't contain control | |
| # chars but env-var passthrough (HERMES_HOME, PYTHONPATH) could | |
| # in pathological cases. | |
| escaped = ( | |
| value | |
| .replace("\\", "\\\\") | |
| .replace('"', '\\"') | |
| .replace("\b", "\\b") | |
| .replace("\t", "\\t") | |
| .replace("\n", "\\n") | |
| .replace("\f", "\\f") | |
| .replace("\r", "\\r") | |
| ) | |
| return f'"{escaped}"' | |
| if isinstance(value, list): | |
| items = ", ".join(_format_toml_value(v) for v in value) | |
| return f"[{items}]" | |
| if isinstance(value, dict): | |
| items = ", ".join( | |
| f'{_quote_key(k)} = {_format_toml_value(v)}' for k, v in value.items() | |
| ) | |
| return "{ " + items + " }" if items else "{}" | |
| raise ValueError(f"Unsupported TOML value type: {type(value).__name__}") | |
| def _quote_key(key: str) -> str: | |
| """Return key bare-or-quoted depending on whether it's a valid bare key.""" | |
| if all(c.isalnum() or c in "-_" for c in key) and key: | |
| return key | |
| escaped = key.replace("\\", "\\\\").replace('"', '\\"') | |
| return f'"{escaped}"' | |
| def render_codex_toml_section( | |
| servers: dict[str, dict], | |
| plugins: Optional[list[dict]] = None, | |
| default_permission_profile: Optional[str] = None, | |
| ) -> str: | |
| """Render the managed [mcp_servers.<n>] / [plugins.<id>] / [permissions] | |
| block for ~/.codex/config.toml. | |
| Args: | |
| servers: dict of MCP server name → translated codex inline-table | |
| plugins: optional list of {name, marketplace, enabled} for native | |
| Codex plugins to enable. (E.g. the Linear / Atlassian / Asana | |
| curated plugins, or per-account ChatGPT apps.) | |
| default_permission_profile: when set, write `[permissions] default` | |
| so the user doesn't get an approval prompt on every write | |
| attempt. Common values: "workspace-write", "read-only", | |
| "full-access". | |
| """ | |
| out = [MIGRATION_MARKER] | |
| if not servers and not plugins and not default_permission_profile: | |
| out.append("# (no MCP servers, plugins, or permissions configured by Hermes)") | |
| out.append(MIGRATION_END_MARKER) | |
| return "\n".join(out) + "\n" | |
| if default_permission_profile: | |
| # Codex's config schema: `default_permissions` is a top-level | |
| # string referencing a profile name. Built-in profile names start | |
| # with ":" (":workspace-write", ":read-only", ":full-access"). The | |
| # [permissions] table is for *user-defined* named profiles with | |
| # structured fields — not what we want. | |
| normalized = ( | |
| default_permission_profile | |
| if default_permission_profile.startswith(":") | |
| else f":{default_permission_profile}" | |
| ) | |
| out.append("") | |
| out.append(f"default_permissions = {_format_toml_value(normalized)}") | |
| if servers: | |
| for name in sorted(servers.keys()): | |
| cfg = servers[name] | |
| out.append("") | |
| out.append(f"[mcp_servers.{_quote_key(name)}]") | |
| for k, v in cfg.items(): | |
| out.append(f"{_quote_key(k)} = {_format_toml_value(v)}") | |
| if plugins: | |
| for plugin in sorted(plugins, key=lambda p: f"{p.get('name','')}@{p.get('marketplace','')}"): | |
| name = plugin.get("name") or "" | |
| marketplace = plugin.get("marketplace") or "openai-curated" | |
| enabled = bool(plugin.get("enabled", True)) | |
| qualified = f"{name}@{marketplace}" | |
| out.append("") | |
| out.append(f'[plugins.{_quote_key(qualified)}]') | |
| out.append(f"enabled = {_format_toml_value(enabled)}") | |
| out.append("") | |
| out.append(MIGRATION_END_MARKER) | |
| return "\n".join(out) + "\n" | |
| def _insert_managed_block_at_top_level(user_text: str, managed_block: str) -> str: | |
| """Insert Hermes' managed Codex TOML block while keeping root keys root-scoped. | |
| TOML has no syntax to return to the document root after a table header. | |
| Therefore appending a root key like `default_permissions = ...` after a | |
| user table such as `[features]` actually creates `features.default_permissions`, | |
| which Codex rejects. Insert the managed block before the first table header | |
| so its root keys remain top-level, while preserving user content verbatim. | |
| """ | |
| if not user_text.strip(): | |
| return managed_block | |
| lines = user_text.splitlines(keepends=True) | |
| first_table_idx: Optional[int] = None | |
| for idx, line in enumerate(lines): | |
| stripped = line.lstrip() | |
| if stripped.startswith("["): | |
| first_table_idx = idx | |
| break | |
| if first_table_idx is None: | |
| prefix = user_text.rstrip("\n") | |
| return f"{prefix}\n\n{managed_block}" if prefix else managed_block | |
| prefix = "".join(lines[:first_table_idx]).rstrip("\n") | |
| suffix = "".join(lines[first_table_idx:]).lstrip("\n") | |
| if prefix: | |
| return f"{prefix}\n\n{managed_block}\n{suffix}" | |
| return f"{managed_block}\n{suffix}" | |
| def _strip_unmanaged_plugin_tables(toml_text: str) -> str: | |
| """Remove ``[plugins."<name>@<marketplace>"]`` tables that live OUTSIDE the | |
| managed block. | |
| Codex itself writes these tables when the user runs ``codex plugins enable`` | |
| directly (i.e. before Hermes' migrate has ever touched the file). When we | |
| later run migrate, ``_query_codex_plugins()`` reports the same plugins via | |
| the live ``plugin/list`` RPC and we re-emit them inside the managed block. | |
| The result without this strip is duplicate ``[plugins."X@Y"]`` table | |
| headers — codex's strict TOML parser then refuses to load the file. | |
| We own the ``[plugins.*]`` namespace once migrate has run, so dropping any | |
| pre-existing ``[plugins.*]`` tables is safe: ``plugin/list`` is the source | |
| of truth for what's actually installed. The caller is expected to only | |
| invoke this strip when ``plugin/list`` succeeded — otherwise we'd lose | |
| plugins the user installed via ``codex`` without a way to re-emit them. | |
| Behavior: | |
| * Lines beginning with ``[plugins.`` start a swallow region that ends at | |
| the next non-``[plugins.`` table header or end-of-file. | |
| * Content inside the managed block is untouched (callers should run | |
| ``_strip_existing_managed_block`` first so the managed block has | |
| already been removed when this runs). | |
| """ | |
| lines = toml_text.splitlines(keepends=True) | |
| out: list[str] = [] | |
| in_plugin_table = False | |
| for line in lines: | |
| stripped = line.lstrip() | |
| # Only treat a line as a table header when it has the shape | |
| # ``[...]`` (optionally followed by a comment). Multi-line array | |
| # continuations like ``["nested"],`` also start with ``[`` after | |
| # lstrip but are not headers — without this guard they would | |
| # falsely flip ``in_plugin_table`` to False mid-table and leak | |
| # array fragments into the output. | |
| if _looks_like_table_header(stripped): | |
| in_plugin_table = stripped.startswith("[plugins.") | |
| if in_plugin_table: | |
| continue | |
| if in_plugin_table: | |
| # Swallow keys/comments/blanks until the next table header. | |
| continue | |
| out.append(line) | |
| return "".join(out) | |
| def _looks_like_table_header(stripped_line: str) -> bool: | |
| """Return True if ``stripped_line`` is a TOML table header. | |
| A header has the shape ``[name]`` or ``[[name]]`` (array-of-tables), | |
| optionally followed by a comment. The closing ``]`` (or ``]]``) must | |
| appear on the same line, and no key-assignment ``=`` can precede it. | |
| This distinguishes real headers from multi-line array continuation | |
| lines that also start with ``[`` after ``lstrip()``. | |
| """ | |
| if not stripped_line.startswith("["): | |
| return False | |
| # Drop trailing comment so e.g. ``[features] # note`` still matches. | |
| head = stripped_line.split("#", 1)[0].rstrip() | |
| if not head.endswith("]"): | |
| return False | |
| # ``key = [x]`` would have an ``=`` before the bracket; a header doesn't. | |
| bracket_idx = head.index("]") | |
| return "=" not in head[: bracket_idx + 1] | |
| def _strip_existing_managed_block(toml_text: str) -> str: | |
| """Remove any prior managed section so re-runs idempotently replace it. | |
| The managed section is everything between MIGRATION_MARKER (start) and | |
| MIGRATION_END_MARKER (end), inclusive of both markers. User-edited | |
| sections above or below are preserved verbatim. | |
| Backward compatibility: if the start marker is found but no end marker | |
| follows, we fall back to the heuristic that swallows lines until we | |
| hit a section that's not [mcp_servers.*]/[plugins.*]/[permissions]/ | |
| a `default_permissions =` key. This matches what older versions of | |
| this code wrote so re-runs don't break configs from prior Hermes | |
| versions.""" | |
| lines = toml_text.splitlines(keepends=True) | |
| out: list[str] = [] | |
| in_managed = False | |
| saw_end_marker = False | |
| for line in lines: | |
| line_stripped_nl = line.rstrip("\n") | |
| if line_stripped_nl == MIGRATION_MARKER: | |
| in_managed = True | |
| saw_end_marker = False | |
| continue | |
| if in_managed: | |
| if line_stripped_nl == MIGRATION_END_MARKER: | |
| in_managed = False | |
| saw_end_marker = True | |
| continue | |
| stripped = line.lstrip() | |
| if not saw_end_marker and stripped.startswith("[") and not ( | |
| stripped.startswith("[mcp_servers") | |
| or stripped.startswith("[plugins") | |
| or stripped.startswith("[permissions]") | |
| or stripped.startswith("[permissions.") | |
| ): | |
| # Old-format managed block without end marker: bail back | |
| # to user content as soon as we see a non-managed section. | |
| in_managed = False | |
| out.append(line) | |
| continue | |
| # Otherwise swallow the line. | |
| continue | |
| out.append(line) | |
| return "".join(out) | |
| def _query_codex_plugins( | |
| codex_home: Optional[Path] = None, | |
| timeout: float = 8.0, | |
| ) -> tuple[list[dict], Optional[str]]: | |
| """Query codex's `plugin/list` for installed curated plugins. | |
| Spawns `codex app-server` briefly, sends initialize + plugin/list, | |
| extracts plugins where installed=true. Returns (plugins, error). | |
| Plugins is a list of {name, marketplace, enabled} dicts ready for | |
| render_codex_toml_section(). | |
| On any failure (codex not installed, RPC error, timeout) returns | |
| ([], error_message). Migration treats this as non-fatal — MCP | |
| servers and permissions still write through. | |
| """ | |
| try: | |
| from agent.transports.codex_app_server import CodexAppServerClient | |
| except Exception as exc: | |
| return [], f"transport unavailable: {exc}" | |
| try: | |
| with CodexAppServerClient( | |
| codex_home=str(codex_home) if codex_home else None | |
| ) as client: | |
| client.initialize(client_name="hermes-migration") | |
| resp = client.request("plugin/list", {}, timeout=timeout) | |
| except Exception as exc: | |
| return [], f"plugin/list query failed: {exc}" | |
| out: list[dict] = [] | |
| seen: set[tuple[str, str]] = set() | |
| marketplaces = resp.get("marketplaces") or [] | |
| if not isinstance(marketplaces, list): | |
| return [], "plugin/list response missing 'marketplaces'" | |
| for marketplace in marketplaces: | |
| if not isinstance(marketplace, dict): | |
| continue | |
| market_name = str(marketplace.get("name") or "openai-curated") | |
| plugins = marketplace.get("plugins") or [] | |
| if not isinstance(plugins, list): | |
| continue | |
| for plugin in plugins: | |
| if not isinstance(plugin, dict): | |
| continue | |
| installed = bool(plugin.get("installed", False)) | |
| if not installed: | |
| continue | |
| # Skip plugins codex itself reports as unavailable (broken | |
| # install, missing OAuth, removed from marketplace, etc.). | |
| # Cf. openclaw/openclaw#80815 — OpenClaw learned to gate | |
| # migration on app readiness to avoid writing config that | |
| # would fail at activation time. Our migration writes to | |
| # codex's config.toml directly, so a broken plugin would | |
| # surface as a codex error on first use. Skipping it here | |
| # keeps the migrated config clean and the user's first | |
| # codex turn from failing. | |
| availability = str(plugin.get("availability") or "").upper() | |
| if availability and availability != "AVAILABLE": | |
| logger.debug( | |
| "skipping plugin %s: availability=%s", | |
| plugin.get("name"), availability, | |
| ) | |
| continue | |
| name = str(plugin.get("name") or "") | |
| if not name: | |
| continue | |
| key = (name, market_name) | |
| if key in seen: | |
| continue | |
| seen.add(key) | |
| # Carry forward whatever 'enabled' codex reports — defaults to | |
| # true for installed plugins. This is the same shape OpenClaw | |
| # writes when migrating native codex plugins. | |
| out.append({ | |
| "name": name, | |
| "marketplace": market_name, | |
| "enabled": bool(plugin.get("enabled", True)), | |
| }) | |
| return out, None | |
| def _looks_like_test_tempdir(path: str) -> bool: | |
| """Heuristic: does ``path`` look like a pytest/transient tempdir? | |
| pytest tempdirs live under ``pytest-of-<user>/pytest-<n>/`` (created via | |
| ``tmp_path`` / ``tmp_path_factory``) and are reaped between sessions. | |
| macOS routes ``/tmp`` through ``/private/var/folders/<…>/T`` which is | |
| what pytest's tempdir factory uses by default. If a HERMES_HOME pointing | |
| at one of those paths is burned into ``~/.codex/config.toml``, every | |
| codex-routed hermes-tools call fails silently once the directory is GC'd. | |
| We err on the side of refusing — losing a (very unlikely) real | |
| ``~/.hermes`` symlink that happens to live under ``/private/var/folders`` | |
| is much less harmful than silently bricking codex's tool surface. | |
| """ | |
| if not path: | |
| return False | |
| needles = ( | |
| "pytest-of-", | |
| "/pytest-", | |
| "/tmp/pytest", | |
| "/private/var/folders/", # macOS tempdir root | |
| ) | |
| normalized = path.lower() | |
| return any(needle in normalized for needle in needles) | |
| def _build_hermes_tools_mcp_entry() -> dict: | |
| """Build the codex stdio-transport entry that launches Hermes' own | |
| tool surface as an MCP server. Codex's subprocess will call back into | |
| this for browser/web/delegate_task/vision/memory/skills tools. | |
| The command runs the worktree's Python via the current sys.executable | |
| so a hermes installed under /opt/, /usr/local/, or a venv all work. | |
| HERMES_HOME and PYTHONPATH are passed through so the spawned process | |
| sees the same config + module layout the user is running.""" | |
| import sys | |
| env: dict[str, str] = {} | |
| # HERMES_HOME passes through IF SET so the MCP subprocess sees the same | |
| # config / auth / sessions DB as the parent CLI. Read from os.environ | |
| # (not get_hermes_home()) on purpose: when the env var is unset we want | |
| # codex's subprocess to inherit whatever HERMES_HOME its launcher sets | |
| # at runtime (systemd unit, gateway, kanban dispatcher, custom shell), | |
| # rather than burning the migrate-time resolved default into config.toml | |
| # — that would override the launcher's HERMES_HOME and pin the subprocess | |
| # to the wrong profile. | |
| # | |
| # The pytest-tempdir guard below catches the issue #26250 Bug C scenario: | |
| # a sibling test's monkeypatch.setenv("HERMES_HOME", tmp_path) would | |
| # otherwise leak a transient pytest tempdir into the user's real | |
| # ~/.codex/config.toml and silently brick codex once the tempdir is GC'd. | |
| hermes_home = os.environ.get("HERMES_HOME") or "" | |
| if hermes_home and _looks_like_test_tempdir(hermes_home): | |
| hermes_home = "" | |
| if hermes_home: | |
| env["HERMES_HOME"] = hermes_home | |
| # PYTHONPATH passes through so a worktree-launched hermes finds the | |
| # branch's modules instead of the installed package. | |
| pythonpath = os.environ.get("PYTHONPATH") | |
| if pythonpath: | |
| env["PYTHONPATH"] = pythonpath | |
| # Quiet mode + redaction defaults so the MCP wire stays clean. | |
| env["HERMES_QUIET"] = "1" | |
| env["HERMES_REDACT_SECRETS"] = env.get("HERMES_REDACT_SECRETS", "true") | |
| out: dict[str, Any] = { | |
| "command": sys.executable, | |
| "args": ["-m", "agent.transports.hermes_tools_mcp_server"], | |
| } | |
| if env: | |
| out["env"] = env | |
| # Generous timeouts — browser_navigate or delegate_task can take a | |
| # while; we don't want codex's MCP client to give up too early. | |
| out["startup_timeout_sec"] = 30.0 | |
| out["tool_timeout_sec"] = 600.0 | |
| return out | |
| def migrate( | |
| hermes_config: dict, | |
| *, | |
| codex_home: Optional[Path] = None, | |
| dry_run: bool = False, | |
| discover_plugins: bool = True, | |
| default_permission_profile: Optional[str] = ":workspace", | |
| expose_hermes_tools: bool = True, | |
| ) -> MigrationReport: | |
| """Translate Hermes mcp_servers config + Codex curated plugins into | |
| ~/.codex/config.toml. | |
| Args: | |
| hermes_config: full ~/.hermes/config.yaml dict | |
| codex_home: override CODEX_HOME (defaults to ~/.codex) | |
| dry_run: skip the actual write; report what would happen | |
| discover_plugins: when True (default), query `plugin/list` against | |
| the live codex CLI to migrate any installed curated plugins | |
| into [plugins."<name>@<marketplace>"] entries. Set False to | |
| skip the subprocess spawn (for tests or restricted environments). | |
| default_permission_profile: when set (default ":workspace"), write | |
| top-level `default_permissions = "<name>"` so users on this | |
| runtime don't get an approval prompt on every write attempt. | |
| Built-in codex profile names are ":workspace", ":read-only", | |
| ":danger-no-sandbox" (note the leading ":"). Also accepts a | |
| user-defined profile name (no leading ":") that the user has | |
| configured in their own [permissions.<name>] table. Set None | |
| to leave permissions unset and let codex use its compiled-in | |
| default (which is read-only). | |
| expose_hermes_tools: when True (default), register Hermes' own | |
| tool surface (web_search, browser_*, delegate_task, vision, | |
| memory, skills, etc.) as an MCP server in ~/.codex/config.toml | |
| so the codex subprocess can call back into Hermes for tools | |
| codex doesn't have built in. Set False to opt out. | |
| """ | |
| report = MigrationReport(dry_run=dry_run) | |
| codex_home = codex_home or Path.home() / ".codex" | |
| target = codex_home / "config.toml" | |
| report.target_path = target | |
| hermes_servers = (hermes_config or {}).get("mcp_servers") or {} | |
| if not isinstance(hermes_servers, dict): | |
| report.errors.append( | |
| "mcp_servers in Hermes config is not a dict; cannot migrate." | |
| ) | |
| return report | |
| translated: dict[str, dict] = {} | |
| for name, cfg in hermes_servers.items(): | |
| out, skipped = _translate_one_server(str(name), cfg or {}) | |
| if out is None: | |
| report.errors.append( | |
| f"server {name!r} skipped: {', '.join(skipped) or 'no transport configured'}" | |
| ) | |
| continue | |
| translated[str(name)] = out | |
| if skipped: | |
| report.skipped_keys_per_server[str(name)] = skipped | |
| report.migrated.append(str(name)) | |
| # Discover installed Codex curated plugins. Best-effort — never blocks | |
| # the migration if codex is unreachable or the RPC fails. | |
| plugins: list[dict] = [] | |
| plugin_query_succeeded = False | |
| if discover_plugins and not dry_run: | |
| plugins, plugin_err = _query_codex_plugins(codex_home=codex_home) | |
| if plugin_err: | |
| report.plugin_query_error = plugin_err | |
| else: | |
| # plugin/list returned authoritatively (even if the list is empty). | |
| # That means we own [plugins.*] for this re-render and can safely | |
| # strip any pre-existing tables outside the managed block. | |
| plugin_query_succeeded = True | |
| for p in plugins: | |
| report.migrated_plugins.append(f"{p['name']}@{p['marketplace']}") | |
| # Track whether we wrote a default permission profile so the report | |
| # surfaces it to the user. | |
| if default_permission_profile: | |
| report.wrote_permissions_default = default_permission_profile | |
| # Inject Hermes' own tool surface as an MCP server so the spawned | |
| # codex subprocess can call back into Hermes for the tools codex | |
| # doesn't ship with — web_search, browser_*, delegate_task, vision, | |
| # memory, skills, session_search, image_generate, text_to_speech. | |
| # The server itself is agent/transports/hermes_tools_mcp_server.py | |
| # and is launched on demand by codex (stdio MCP). | |
| if expose_hermes_tools: | |
| translated["hermes-tools"] = _build_hermes_tools_mcp_entry() | |
| if "hermes-tools" not in report.migrated: | |
| report.migrated.append("hermes-tools") | |
| # Build the new managed block | |
| managed_block = render_codex_toml_section( | |
| translated, plugins=plugins, | |
| default_permission_profile=default_permission_profile, | |
| ) | |
| # Read existing codex config if any, strip the prior managed block, | |
| # append the new one. | |
| if target.exists(): | |
| try: | |
| existing = target.read_text(encoding="utf-8") | |
| except Exception as exc: | |
| report.errors.append(f"could not read {target}: {exc}") | |
| return report | |
| without_managed = _strip_existing_managed_block(existing) | |
| # Bug B: when plugin/list ran authoritatively, codex's own | |
| # [plugins."<name>@<marketplace>"] tables outside our managed block | |
| # would survive _strip_existing_managed_block and then collide with | |
| # the entries we re-emit inside the managed block — producing | |
| # duplicate-table-header parse errors on codex's next startup. Drop | |
| # those pre-existing tables since plugin/list is the source of truth. | |
| if plugin_query_succeeded: | |
| without_managed = _strip_unmanaged_plugin_tables(without_managed) | |
| new_text = _insert_managed_block_at_top_level(without_managed, managed_block) | |
| else: | |
| new_text = managed_block | |
| if dry_run: | |
| return report | |
| try: | |
| codex_home.mkdir(parents=True, exist_ok=True) | |
| # Atomic write: write to a temp file in the same directory then | |
| # rename. Same-directory rename is atomic on POSIX and ReplaceFile | |
| # on Windows. Avoids leaving a half-written config.toml that | |
| # codex would refuse to load if we crash mid-write. | |
| import tempfile | |
| tmp_fd, tmp_path_str = tempfile.mkstemp( | |
| prefix=".config.toml.", dir=str(codex_home) | |
| ) | |
| tmp_path = Path(tmp_path_str) | |
| try: | |
| with os.fdopen(tmp_fd, "w", encoding="utf-8") as fh: | |
| fh.write(new_text) | |
| tmp_path.replace(target) | |
| except Exception: | |
| # Clean up the temp file if the rename didn't happen. | |
| try: | |
| if tmp_path.exists(): | |
| tmp_path.unlink() | |
| except Exception: | |
| pass | |
| raise | |
| report.written = True | |
| except Exception as exc: | |
| report.errors.append(f"could not write {target}: {exc}") | |
| return report | |