| """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__)
|
|
|
|
|
|
|
|
|
| MIGRATION_MARKER = (
|
| "# managed by hermes-agent — `hermes codex-runtime migrate` regenerates this section"
|
| )
|
| MIGRATION_END_MARKER = (
|
| "# end hermes-agent managed section"
|
| )
|
|
|
|
|
| @dataclass
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
| _KNOWN_HERMES_KEYS = {
|
|
|
| "command", "args", "env", "cwd",
|
|
|
| "url", "headers", "transport",
|
|
|
| "timeout", "connect_timeout",
|
|
|
| "enabled", "description",
|
| }
|
|
|
|
|
| _KEYS_DROPPED_WITH_WARNING = {
|
|
|
| "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:
|
|
|
| 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:
|
|
|
| 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:
|
|
|
| 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()}
|
|
|
| if hermes_cfg.get("transport") == "sse":
|
| skipped.append("transport=sse (codex auto-negotiates)")
|
| else:
|
| return None, ["no command or url field"]
|
|
|
|
|
| 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)")
|
|
|
|
|
| if hermes_cfg.get("enabled") is False:
|
| out["enabled"] = False
|
|
|
|
|
| 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):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
|
|
|
|
|
|
|
| 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()
|
|
|
|
|
|
|
|
|
|
|
|
|
| if _looks_like_table_header(stripped):
|
| in_plugin_table = stripped.startswith("[plugins.")
|
| if in_plugin_table:
|
| continue
|
| if in_plugin_table:
|
|
|
| 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
|
|
|
| head = stripped_line.split("#", 1)[0].rstrip()
|
| if not head.endswith("]"):
|
| return False
|
|
|
| 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.")
|
| ):
|
|
|
|
|
| in_managed = False
|
| out.append(line)
|
| continue
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
| 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/",
|
| )
|
| 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 = 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 = os.environ.get("PYTHONPATH")
|
| if pythonpath:
|
| env["PYTHONPATH"] = pythonpath
|
|
|
| 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
|
|
|
|
|
| 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))
|
|
|
|
|
|
|
| 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_query_succeeded = True
|
| for p in plugins:
|
| report.migrated_plugins.append(f"{p['name']}@{p['marketplace']}")
|
|
|
|
|
|
|
| if default_permission_profile:
|
| report.wrote_permissions_default = default_permission_profile
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if expose_hermes_tools:
|
| translated["hermes-tools"] = _build_hermes_tools_mcp_entry()
|
| if "hermes-tools" not in report.migrated:
|
| report.migrated.append("hermes-tools")
|
|
|
|
|
| managed_block = render_codex_toml_section(
|
| translated, plugins=plugins,
|
| default_permission_profile=default_permission_profile,
|
| )
|
|
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
| 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
|
|
|