diff --git a/mureo/_data/web/auth_wizards.js b/mureo/_data/web/auth_wizards.js
index 21771ba..2d00b68 100644
--- a/mureo/_data/web/auth_wizards.js
+++ b/mureo/_data/web/auth_wizards.js
@@ -340,6 +340,18 @@
if (isHosted) {
wrap.innerHTML =
"
" + MUREO.t("wizard.provider_banner." + platform) + "
";
+ // Codex has no claude.ai account connector, so Meta's hosted MCP
+ // can't be wired at all — there are no connector steps to show.
+ // Surface the "not available, native stays" note and let the user
+ // proceed; mureo-native Meta is never disabled here.
+ if (state.host === "codex") {
+ const note = document.createElement("p");
+ note.className = "dashboard-provider-hosted-note";
+ note.textContent = MUREO.t("dashboard.provider_codex_hosted_na_note");
+ note.setAttribute("data-i18n", "dashboard.provider_codex_hosted_na_note");
+ wrap.appendChild(note);
+ return wrap;
+ }
if (!showManualSetup()) onComplete();
return wrap;
}
diff --git a/mureo/_data/web/dashboard.js b/mureo/_data/web/dashboard.js
index e12d2d1..a0d29e9 100644
--- a/mureo/_data/web/dashboard.js
+++ b/mureo/_data/web/dashboard.js
@@ -135,6 +135,8 @@
? "wizard.host.claude_desktop"
: status.host === "claude-code"
? "wizard.host.claude_code"
+ : status.host === "codex"
+ ? "wizard.host.codex"
: null;
if (hostKey) {
node.textContent = MUREO.t(hostKey);
@@ -291,6 +293,10 @@
// rejects http config; Meta's hosted MCP has no OAuth DCR).
// Only the Connectors instruction applies.
appendNote("dashboard.provider_desktop_connectors_note");
+ } else if (status && status.host === "codex") {
+ // Codex has no account-level connector for Meta's hosted MCP,
+ // so the official path isn't available — keep mureo-native Meta.
+ appendNote("dashboard.provider_codex_hosted_na_note");
} else {
appendNote("dashboard.provider_hosted_oauth_note");
}
diff --git a/mureo/_data/web/i18n.json b/mureo/_data/web/i18n.json
index 26f708f..6a4fec1 100644
--- a/mureo/_data/web/i18n.json
+++ b/mureo/_data/web/i18n.json
@@ -19,16 +19,17 @@
"wizard.next": "Next",
"wizard.skip": "Skip",
"wizard.step_progress": "Step {current} of {total}",
- "wizard.host.title": "Choose your Claude app",
- "wizard.host.claude_code": "Claude Code (CLI, Desktop app)",
- "wizard.host.claude_desktop": "Claude Desktop app (Chat, Cowork)",
- "wizard.host.sync_failed": "Couldn't save your Claude app choice to the local helper. Check it's still running, then re-select your app.",
+ "wizard.host.title": "Choose your AI agent",
+ "wizard.host.claude_code": "Claude Code — coding agent (CLI / IDE / app)",
+ "wizard.host.claude_desktop": "Claude Desktop — chat app (Chat / Cowork)",
+ "wizard.host.codex": "OpenAI Codex (CLI, IDE extension, desktop app)",
+ "wizard.host.sync_failed": "Couldn't save your AI agent choice to the local helper. Check it's still running, then re-select your agent.",
"wizard.basic.title": "mureo basic setup",
"wizard.basic.desc": "We will run the three mureo setup steps. Safe to re-run.",
"wizard.basic.install_button": "Install all three",
"wizard.basic.mureo_mcp": "mureo MCP server",
"wizard.basic.auth_hook": "Credential guard hook",
- "wizard.basic.auth_hook_desktop_na": "(not available on the Desktop app)",
+ "wizard.basic.auth_hook_desktop_na": "(not available on Claude Desktop — the chat app)",
"wizard.basic.skills": "Workflow skills",
"wizard.basic.advanced_skip": "Advanced users who only call official MCPs directly may skip this step.",
"wizard.basic.advanced_skip_note": "Skipped basic setup. Click Next to continue.",
@@ -126,7 +127,7 @@
"dashboard.nav_demo": "Demo",
"dashboard.nav_byod": "BYOD",
"dashboard.nav_danger": "Danger Zone",
- "dashboard.host_title": "Claude app",
+ "dashboard.host_title": "AI agent",
"dashboard.basic_title": "mureo basic setup",
"dashboard.native_title": "mureo integrations",
"dashboard.native_none": "No platforms connected via mureo yet.",
@@ -225,6 +226,7 @@
"dashboard.tooluse_err_generic": "Could not switch. Please try again.",
"dashboard.provider_hosted_oauth_note": "Registered as a hosted MCP. Finish in Claude Code: run /mcp → Authenticate (Meta login), then reconnect. Meta has no OAuth dynamic client registration — if /mcp reports that, re-register with `claude mcp add --transport http --client-id … --client-secret --callback-port …`, or use the account-level claude.ai Meta Ads connector.",
"dashboard.provider_desktop_connectors_note": "Can't be added from here. In Claude Desktop, open Settings → Connectors → Add custom connector, enter the URL https://mcp.facebook.com/ads, then sign in with Meta.",
+ "dashboard.provider_codex_hosted_na_note": "Meta's official hosted MCP isn't available on Codex (no account-level connector). Keep using mureo's built-in Meta tools instead.",
"connector.setup_title": "Add Meta Ads in Claude Desktop",
"connector.setup_lead": "Meta's hosted MCP can't be configured from here (it has no OAuth dynamic client registration). Add it once in Claude Desktop:",
"connector.step1": "Open Claude Desktop → Settings → Connectors",
@@ -335,16 +337,17 @@
"wizard.next": "次へ",
"wizard.skip": "スキップ",
"wizard.step_progress": "ステップ {current} / {total}",
- "wizard.host.title": "設定先を選んでください",
- "wizard.host.claude_code": "Claude Code(CLI、デスクトップアプリ)",
- "wizard.host.claude_desktop": "Claudeデスクトップアプリ(Chat、Cowork)",
- "wizard.host.sync_failed": "Claude アプリの選択をローカルヘルパーに保存できませんでした。起動中か確認し、アプリを選び直してください。",
+ "wizard.host.title": "AIエージェントを選んでください",
+ "wizard.host.claude_code": "Claude Code — コーディングエージェント(CLI / IDE / アプリ)",
+ "wizard.host.claude_desktop": "Claude Desktop — チャットアプリ(Chat / Cowork)",
+ "wizard.host.codex": "OpenAI Codex(CLI、IDE拡張、デスクトップアプリ)",
+ "wizard.host.sync_failed": "AIエージェントの選択をローカルヘルパーに保存できませんでした。起動中か確認し、エージェントを選び直してください。",
"wizard.basic.title": "基本セットアップ",
"wizard.basic.desc": "mureo の 3 つのセットアップを順次実行します。再実行しても安全です。",
"wizard.basic.install_button": "すべてセットアップする",
"wizard.basic.mureo_mcp": "mureo MCP サーバ",
"wizard.basic.auth_hook": "認証保護 hook",
- "wizard.basic.auth_hook_desktop_na": "(デスクトップアプリでは利用できません)",
+ "wizard.basic.auth_hook_desktop_na": "(Claude Desktop=チャットアプリでは利用できません)",
"wizard.basic.skills": "ワークフロースキル",
"wizard.basic.advanced_skip": "公式 MCP のみで raw 呼出する上級者はこちらから skip 可。",
"wizard.basic.advanced_skip_note": "基本セットアップをスキップしました。「次へ」で続行できます。",
@@ -541,6 +544,7 @@
"dashboard.tooluse_err_generic": "切り替えできませんでした。再度お試しください。",
"dashboard.provider_hosted_oauth_note": "hosted MCP として登録済み。Claude Code で仕上げてください: /mcp → Authenticate(Meta ログイン)→ 再接続。Meta は OAuth 動的クライアント登録に非対応のため、/mcp がそのエラーを出す場合は `claude mcp add --transport http --client-id … --client-secret --callback-port …` で再登録するか、アカウント単位の claude.ai Meta Ads コネクタを利用してください。",
"dashboard.provider_desktop_connectors_note": "ここからは追加できません。Claude Desktop の 設定 → コネクタ → カスタムコネクタを追加 で URL https://mcp.facebook.com/ads を入力し、Meta でログインしてください。",
+ "dashboard.provider_codex_hosted_na_note": "Meta公式のホスト型MCPはCodexでは利用できません(アカウント単位のコネクタがありません)。mureo内蔵のMetaツールをそのままご利用ください。",
"connector.setup_title": "Claude Desktop で Meta Ads を追加",
"connector.setup_lead": "この hosted MCP はここからは設定できません(Meta は OAuth の動的クライアント登録に非対応)。Claude Desktop で一度だけ追加してください:",
"connector.step1": "Claude Desktop → 設定 → コネクタ を開く",
diff --git a/mureo/_data/web/wizard.js b/mureo/_data/web/wizard.js
index 6b9e5e7..b2e4040 100644
--- a/mureo/_data/web/wizard.js
+++ b/mureo/_data/web/wizard.js
@@ -24,7 +24,9 @@
function readStoredHost() {
try {
const v = window.localStorage.getItem(HOST_KEY);
- return v === "claude-code" || v === "claude-desktop" ? v : null;
+ return v === "claude-code" || v === "claude-desktop" || v === "codex"
+ ? v
+ : null;
} catch (_e) {
return null;
}
@@ -239,7 +241,9 @@
'
" +
'" +
+ MUREO.t("wizard.host.claude_desktop") + "
" +
+ '" +
"";
// Assert the current selection to the server on step entry too —
// not only on a radio `change`. A resumed wizard / restarted
@@ -477,7 +481,11 @@
// the user adds it as a Claude.ai account connector. Don't list
// Meta as "saved"; surface an explicit, actionable reminder (the
// pending_meta copy covers both hosts).
+ // Codex has no claude.ai connector, so official Meta is never the
+ // active path there (mureo-native Meta stays) — don't show the
+ // Claude.ai-connector reminder; Meta surfaces as a normal saved row.
const metaPending =
+ STATE.host !== "codex" &&
STATE.platforms.meta_ads &&
STATE.providerChoice.meta_ads === "official";
diff --git a/mureo/cli/setup_codex.py b/mureo/cli/setup_codex.py
index 51a9732..be52a19 100644
--- a/mureo/cli/setup_codex.py
+++ b/mureo/cli/setup_codex.py
@@ -160,13 +160,18 @@ def install_codex_mcp_config() -> Path | None:
# ---------------------------------------------------------------------------
-def install_codex_credential_guard() -> Path | None:
+def install_codex_credential_guard(hooks_file: Path | None = None) -> Path | None:
"""Append PreToolUse hooks to ``~/.codex/hooks.json``.
Existing hook entries are preserved. Returns the path on first
install or ``None`` if the mureo tag is already present.
+
+ ``hooks_file`` overrides the target (the home-aware configure-UI flow
+ passes ``/.codex/hooks.json``); it defaults to the real
+ ``~/.codex/hooks.json`` for the ``mureo setup codex`` CLI.
"""
- hooks_file = Path.home() / ".codex" / "hooks.json"
+ if hooks_file is None:
+ hooks_file = Path.home() / ".codex" / "hooks.json"
hooks_file.parent.mkdir(parents=True, exist_ok=True)
existing: dict[str, Any] = {}
@@ -206,6 +211,51 @@ def install_codex_credential_guard() -> Path | None:
return hooks_file
+def remove_codex_credential_guard(hooks_file: Path | None = None) -> Path | None:
+ """Drop the mureo-tagged PreToolUse hooks from ``~/.codex/hooks.json``.
+
+ The inverse of :func:`install_codex_credential_guard`: removes only the
+ entries whose command carries the ``[mureo-credential-guard]`` tag, and
+ preserves every other hook. Returns the path when something was removed,
+ or ``None`` when the file is absent/unparseable or no tagged entry was
+ present (idempotent). ``hooks_file`` mirrors the install override.
+ """
+ if hooks_file is None:
+ hooks_file = Path.home() / ".codex" / "hooks.json"
+ if not hooks_file.exists():
+ return None
+ try:
+ parsed = json.loads(hooks_file.read_text(encoding="utf-8"))
+ except (json.JSONDecodeError, OSError):
+ logger.warning("Could not parse %s — refusing to overwrite", hooks_file)
+ return None
+ if not isinstance(parsed, dict):
+ return None
+ pre_tool_use = parsed.get("PreToolUse")
+ if not isinstance(pre_tool_use, list):
+ return None
+
+ def _is_mureo_entry(entry: Any) -> bool:
+ if not isinstance(entry, dict):
+ return False
+ return any(
+ _GUARD_TAG in hook.get("command", "")
+ for hook in entry.get("hooks", [])
+ if isinstance(hook, dict)
+ )
+
+ kept = [entry for entry in pre_tool_use if not _is_mureo_entry(entry)]
+ if len(kept) == len(pre_tool_use):
+ return None # nothing tagged — idempotent no-op
+ parsed["PreToolUse"] = kept
+ _atomic_write_text(
+ hooks_file,
+ json.dumps(parsed, indent=2, ensure_ascii=False) + "\n",
+ )
+ logger.info("Codex credential guard removed: %s", hooks_file)
+ return hooks_file
+
+
# ---------------------------------------------------------------------------
# 3. Skills (operational + foundation)
# ---------------------------------------------------------------------------
diff --git a/mureo/web/codex_mcp.py b/mureo/web/codex_mcp.py
new file mode 100644
index 0000000..d6e9df8
--- /dev/null
+++ b/mureo/web/codex_mcp.py
@@ -0,0 +1,480 @@
+"""Tag-marker ``[mcp_servers.]`` writer/remover for OpenAI Codex.
+
+Codex reads its MCP servers from ``~/.codex/config.toml`` — **TOML**, not
+JSON — so the JSON writers (``config_writer`` for Claude Code, ``desktop_mcp``
+for Claude Desktop) do not apply. This module gives the configure UI the same
+per-host surface for Codex.
+
+Design — tagged regions, not a TOML round-trip
+----------------------------------------------
+Operators routinely hand-edit ``config.toml`` (comments, ordering, other MCP
+servers). A full parse → mutate → serialise round-trip would clobber that, so
+— exactly like :mod:`mureo.cli.setup_codex` — each mureo-managed server block
+is wrapped in a tagged region::
+
+ # >>> mureo-mcp:mureo >>>
+ [mcp_servers.mureo]
+ command = "python"
+ args = ["-m", "mureo.mcp"]
+ # <<< mureo-mcp:mureo <<<
+
+Only the bytes between a region's markers are ever read or rewritten; every
+other byte of the file is preserved verbatim. The markers are TOML comments,
+so the file stays valid TOML for Codex.
+
+Conflict safety
+---------------
+Appending ``[mcp_servers.]`` when an **untagged** block of the same name
+already exists would create a duplicate TOML key that Codex rejects. The
+installer refuses to guess whether that block is stale or hand-authored and
+raises :class:`CodexConfigConflictError` (mirrors
+``setup_codex.CodexMcpConflictError``).
+
+``~/.mureo/credentials.json`` is never read, written, or deleted here.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import os
+import re
+import tempfile
+from collections.abc import Mapping
+from typing import TYPE_CHECKING, Any
+
+from mureo.web.host_paths import get_host_paths
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+__all__ = [
+ "CodexConfigConflictError",
+ "install_codex_mcp_block",
+ "install_codex_server_block",
+ "installed_codex_server_ids",
+ "is_codex_server_installed",
+ "read_codex_server_env",
+ "remove_codex_mcp_block",
+ "remove_codex_server_block",
+ "resolve_codex_config_path",
+ "set_mureo_disable_env_codex",
+ "unset_mureo_disable_env_codex",
+]
+
+_MUREO_SERVER_ID = "mureo"
+
+
+class CodexConfigConflictError(Exception):
+ """An untagged ``[mcp_servers.]`` block already exists.
+
+ Adopting it automatically would risk a duplicate-key TOML error at the
+ next Codex launch; the operator must reconcile it manually.
+ """
+
+
+def resolve_codex_config_path(home: Path | None = None) -> Path:
+ """Resolve ``~/.codex/config.toml`` via ``host_paths`` (home-aware)."""
+ return get_host_paths("codex", home=home).mcp_registry_path
+
+
+# ---------------------------------------------------------------------------
+# Tagged-region helpers
+# ---------------------------------------------------------------------------
+
+
+def _begin(server_id: str) -> str:
+ return f"# >>> mureo-mcp:{server_id} >>>"
+
+
+def _end(server_id: str) -> str:
+ return f"# <<< mureo-mcp:{server_id} <<<"
+
+
+def _region_span(text: str, server_id: str) -> tuple[int, int] | None:
+ """Return ``(start, end)`` byte offsets of the tagged region, or ``None``.
+
+ The span is inclusive of both marker lines and a trailing newline so a
+ removed region leaves no blank gap.
+ """
+ begin = _begin(server_id)
+ end = _end(server_id)
+ start = text.find(begin)
+ if start == -1:
+ return None
+ end_idx = text.find(end, start)
+ if end_idx == -1:
+ return None
+ stop = end_idx + len(end)
+ # Swallow one trailing newline so repeated install/remove cycles don't
+ # accrete blank lines.
+ if stop < len(text) and text[stop] == "\n":
+ stop += 1
+ return start, stop
+
+
+def _toml_str(value: str) -> str:
+ """Render ``value`` as a TOML basic string (quotes + escapes)."""
+ escaped = (
+ value.replace("\\", "\\\\")
+ .replace('"', '\\"')
+ .replace("\n", "\\n")
+ .replace("\r", "\\r")
+ .replace("\t", "\\t")
+ )
+ return f'"{escaped}"'
+
+
+def _toml_str_array(values: list[str]) -> str:
+ return "[" + ", ".join(_toml_str(str(v)) for v in values) + "]"
+
+
+_BARE_KEY_RE = re.compile(r"^[A-Za-z0-9_-]+$")
+
+
+def _toml_key(key: str) -> str:
+ """Render a TOML table key: bare when safe, else a quoted basic-string.
+
+ Every env key in practice is from a closed allow-list (``GOOGLE_*`` /
+ ``META_*`` / ``MUREO_DISABLE_*`` — all bare-safe); quoting an odd key is
+ defense-in-depth so a stray name can never break out of the assignment
+ and produce invalid TOML.
+ """
+ return key if _BARE_KEY_RE.match(key) else _toml_str(key)
+
+
+def _coerce_block(server_config: Mapping[str, Any]) -> dict[str, Any]:
+ """Normalise a (possibly frozen) catalog block to a plain stdio dict.
+
+ Codex provider blocks are always stdio: ``command`` (str), ``args``
+ (list[str]), optional ``env`` (str→str). Hosted_http providers have no
+ Codex connector and are handled as ``manual_required`` upstream, so a
+ ``url`` shape never reaches here. ``type`` (a Claude add-json artefact)
+ is dropped — Codex infers stdio from ``command``.
+ """
+ command = str(server_config.get("command", ""))
+ raw_args = server_config.get("args", [])
+ args = [str(a) for a in raw_args] if isinstance(raw_args, (list, tuple)) else []
+ raw_env = server_config.get("env", {})
+ env = (
+ {str(k): str(v) for k, v in raw_env.items()}
+ if isinstance(raw_env, Mapping)
+ else {}
+ )
+ return {"command": command, "args": args, "env": env}
+
+
+def _render_region(server_id: str, block: Mapping[str, Any]) -> str:
+ """Render the full tagged region (markers + TOML body) for one server."""
+ lines = [
+ _begin(server_id),
+ f"[mcp_servers.{server_id}]",
+ f"command = {_toml_str(str(block.get('command', '')))}",
+ f"args = {_toml_str_array(list(block.get('args', [])))}",
+ ]
+ env = block.get("env") or {}
+ if env:
+ lines.append("")
+ lines.append(f"[mcp_servers.{server_id}.env]")
+ for key in sorted(env):
+ lines.append(f"{_toml_key(key)} = {_toml_str(str(env[key]))}")
+ lines.append(_end(server_id))
+ return "\n".join(lines) + "\n"
+
+
+_ENV_LINE_RE = re.compile(r'^(?P[A-Za-z_][A-Za-z0-9_]*)\s*=\s*"(?P.*)"\s*$')
+_ARGS_RE = re.compile(r"^args\s*=\s*\[(?P.*)\]\s*$")
+_COMMAND_RE = re.compile(r'^command\s*=\s*"(?P.*)"\s*$')
+_ITEM_RE = re.compile(r'"((?:[^"\\]|\\.)*)"')
+
+
+_TOML_UNESCAPE = {"\\": "\\", '"': '"', "n": "\n", "r": "\r", "t": "\t"}
+
+
+def _unescape_toml(value: str) -> str:
+ """Inverse of :func:`_toml_str` — a single left-to-right scan.
+
+ Chained ``str.replace`` calls are NOT a correct inverse: after ``\\\\``
+ collapses to one backslash, a following ``n``/``t``/``"`` would be
+ mis-read as an escape. A windows ``command`` like ``C:\\nina`` (rendered
+ on disk as ``"C:\\\\nina"``) must round-trip back to ``C:\\nina``, not
+ ``C:ina`` — otherwise the env-toggle re-render emits invalid
+ TOML and Codex fails to parse the whole file. A scanner consumes the
+ backslash and exactly one following char, so it is order-independent.
+ """
+ out: list[str] = []
+ i, n = 0, len(value)
+ while i < n:
+ ch = value[i]
+ if ch == "\\" and i + 1 < n:
+ nxt = value[i + 1]
+ out.append(_TOML_UNESCAPE.get(nxt, "\\" + nxt))
+ i += 2
+ else:
+ out.append(ch)
+ i += 1
+ return "".join(out)
+
+
+def _parse_region(region_text: str, server_id: str) -> dict[str, Any]:
+ """Parse a region this module rendered back into ``{command, args, env}``.
+
+ Tolerant line parsing of our own canonical format — never the operator's
+ surrounding TOML. Missing pieces default empty so a hand-trimmed region
+ degrades to a re-render rather than a crash.
+ """
+ command = ""
+ args: list[str] = []
+ env: dict[str, str] = {}
+ in_env = False
+ env_header = f"[mcp_servers.{server_id}.env]"
+ block_header = f"[mcp_servers.{server_id}]"
+ for raw in region_text.splitlines():
+ line = raw.strip()
+ if not line or line.startswith("#"):
+ continue
+ if line == env_header:
+ in_env = True
+ continue
+ if line == block_header:
+ in_env = False
+ continue
+ if in_env:
+ m = _ENV_LINE_RE.match(line)
+ if m:
+ env[m.group("key")] = _unescape_toml(m.group("val"))
+ continue
+ cmd = _COMMAND_RE.match(line)
+ if cmd:
+ command = _unescape_toml(cmd.group("val"))
+ continue
+ arr = _ARGS_RE.match(line)
+ if arr:
+ args = [_unescape_toml(i) for i in _ITEM_RE.findall(arr.group("body"))]
+ return {"command": command, "args": args, "env": env}
+
+
+def _untagged_block_present(text: str, server_id: str) -> bool:
+ """True iff ``[mcp_servers.]`` appears outside our tagged region.
+
+ Guards against producing a duplicate TOML key. The header is matched at a
+ line start so ``[mcp_servers..env]`` (a sub-table) does not count.
+ """
+ span = _region_span(text, server_id)
+ header = f"[mcp_servers.{server_id}]"
+ pattern = re.compile(r"^\s*" + re.escape(header) + r"\s*$", re.MULTILINE)
+ for m in pattern.finditer(text):
+ if span is None or not (span[0] <= m.start() < span[1]):
+ return True
+ return False
+
+
+def _atomic_write_text(path: Path, content: str) -> None:
+ """Write ``content`` atomically (temp file + fsync + rename)."""
+ path.parent.mkdir(parents=True, exist_ok=True)
+ fd, tmp_name = tempfile.mkstemp(
+ prefix=path.name + ".", suffix=".tmp", dir=path.parent
+ )
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
+ fh.write(content)
+ fh.flush()
+ os.fsync(fh.fileno())
+ os.replace(tmp_name, path)
+ except BaseException:
+ with contextlib.suppress(OSError):
+ os.unlink(tmp_name)
+ raise
+
+
+def _collapse_seam(head: str, tail: str) -> str:
+ """Join ``head``+``tail`` after a region removal, tidying ONLY the seam.
+
+ Removing a middle region can leave a 3+ newline run straddling the
+ junction (e.g. ``...\\n\\n`` + ``\\n[next]``). Collapse that run to a
+ single blank line — but only the run touching the seam, so an
+ operator's intentional blank-line spacing elsewhere in the file is
+ preserved verbatim (the module's "every other byte" contract).
+ """
+ joined = head + tail
+ seam = len(head)
+ start = seam
+ while start > 0 and joined[start - 1] == "\n":
+ start -= 1
+ end = seam
+ while end < len(joined) and joined[end] == "\n":
+ end += 1
+ if end - start >= 3:
+ joined = joined[:start] + "\n\n" + joined[end:]
+ return joined
+
+
+def _splice_region(text: str, server_id: str, region: str | None) -> str:
+ """Replace/remove the tagged region in ``text``; append when adding new.
+
+ ``region=None`` removes; a string replaces an existing region or appends
+ a new one (separated by a blank line from prior content).
+ """
+ span = _region_span(text, server_id)
+ if span is not None:
+ head, tail = text[: span[0]], text[span[1] :]
+ if region is None:
+ return _collapse_seam(head, tail)
+ return head + region + tail
+ if region is None:
+ return text
+ if not text:
+ return region
+ separator = (
+ "" if text.endswith("\n\n") else ("\n" if text.endswith("\n") else "\n\n")
+ )
+ return text + separator + region
+
+
+# ---------------------------------------------------------------------------
+# Public surface (mirrors desktop_mcp.py for parity)
+# ---------------------------------------------------------------------------
+
+
+def install_codex_server_block(
+ config_path: Path,
+ server_id: str,
+ server_config: Mapping[str, Any],
+) -> bool:
+ """Surgically register ``[mcp_servers.]`` in the Codex config.
+
+ Returns ``True`` when the region was written, ``False`` when an identical
+ tagged region already exists (idempotent — file byte-identical). Raises
+ :class:`CodexConfigConflictError` when an untagged block of the same name
+ exists (a duplicate-key hazard). All other file content is preserved.
+ """
+ text = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
+ if _untagged_block_present(text, server_id):
+ raise CodexConfigConflictError(
+ f"An untagged [mcp_servers.{server_id}] block already exists in "
+ f"{config_path}. Remove it and retry, or wrap it in the mureo tag "
+ f"markers ({_begin(server_id)} ... {_end(server_id)}) to adopt it."
+ )
+ region = _render_region(server_id, _coerce_block(server_config))
+ span = _region_span(text, server_id)
+ if span is not None and text[span[0] : span[1]] == region:
+ return False
+ _atomic_write_text(config_path, _splice_region(text, server_id, region))
+ return True
+
+
+def install_codex_mcp_block(
+ config_path: Path,
+ command: str,
+ args: list[str],
+) -> bool:
+ """Register the ``[mcp_servers.mureo]`` block in the Codex config.
+
+ Presence-based idempotency (mirrors ``install_desktop_mcp_block``):
+ returns ``False`` when ANY ``mureo`` region already exists so a
+ stale/legacy block is never silently overwritten.
+ """
+ text = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
+ if _region_span(text, _MUREO_SERVER_ID) is not None:
+ return False
+ return install_codex_server_block(
+ config_path,
+ _MUREO_SERVER_ID,
+ {"command": command, "args": list(args)},
+ )
+
+
+def remove_codex_server_block(config_path: Path, server_id: str) -> bool:
+ """Remove only the ``[mcp_servers.]`` region. Idempotent."""
+ if not config_path.exists():
+ return False
+ text = config_path.read_text(encoding="utf-8")
+ if _region_span(text, server_id) is None:
+ return False
+ _atomic_write_text(config_path, _splice_region(text, server_id, None))
+ return True
+
+
+def remove_codex_mcp_block(config_path: Path) -> bool:
+ """Remove only the ``[mcp_servers.mureo]`` region. Idempotent."""
+ return remove_codex_server_block(config_path, _MUREO_SERVER_ID)
+
+
+def set_mureo_disable_env_codex(config_path: Path, env_var: str) -> bool:
+ """Set ``[mcp_servers.mureo.env] = "1"``. Idempotent ``False``.
+
+ Re-renders the mureo region preserving its command/args and any other
+ env keys. No-op (``False``) when there is no mureo region or the var is
+ already ``"1"``.
+ """
+ if not config_path.exists():
+ return False
+ text = config_path.read_text(encoding="utf-8")
+ span = _region_span(text, _MUREO_SERVER_ID)
+ if span is None:
+ return False
+ block = _parse_region(text[span[0] : span[1]], _MUREO_SERVER_ID)
+ if block["env"].get(env_var) == "1":
+ return False
+ block["env"][env_var] = "1"
+ _atomic_write_text(
+ config_path,
+ _splice_region(text, _MUREO_SERVER_ID, _render_region(_MUREO_SERVER_ID, block)),
+ )
+ return True
+
+
+def unset_mureo_disable_env_codex(config_path: Path, env_var: str) -> bool:
+ """Pop ``[mcp_servers.mureo.env] ``. Idempotent ``False`` no-op."""
+ if not config_path.exists():
+ return False
+ text = config_path.read_text(encoding="utf-8")
+ span = _region_span(text, _MUREO_SERVER_ID)
+ if span is None:
+ return False
+ block = _parse_region(text[span[0] : span[1]], _MUREO_SERVER_ID)
+ if env_var not in block["env"]:
+ return False
+ del block["env"][env_var]
+ _atomic_write_text(
+ config_path,
+ _splice_region(text, _MUREO_SERVER_ID, _render_region(_MUREO_SERVER_ID, block)),
+ )
+ return True
+
+
+def is_codex_server_installed(config_path: Path, server_id: str) -> bool:
+ """True iff a mureo-tagged ``[mcp_servers.]`` region exists."""
+ if not config_path.exists():
+ return False
+ return _region_span(config_path.read_text(encoding="utf-8"), server_id) is not None
+
+
+_REGION_ID_RE = re.compile(
+ r"^# >>> mureo-mcp:(?P[A-Za-z0-9._-]+) >>>", re.MULTILINE
+)
+
+
+def installed_codex_server_ids(config_path: Path) -> set[str]:
+ """All mureo-managed server ids present in the Codex config (tag scan)."""
+ if not config_path.exists():
+ return set()
+ text = config_path.read_text(encoding="utf-8")
+ return {m.group("id") for m in _REGION_ID_RE.finditer(text)}
+
+
+def read_codex_server_env(config_path: Path, server_id: str) -> dict[str, str]:
+ """Return the ``env`` table of a mureo-managed server, ``{}`` if absent.
+
+ Read-only; used by the status snapshot to surface which
+ ``MUREO_DISABLE_`` flags the mureo block currently carries.
+ Parses only this module's own tagged region, never the operator's
+ surrounding TOML.
+ """
+ if not config_path.exists():
+ return {}
+ text = config_path.read_text(encoding="utf-8")
+ span = _region_span(text, server_id)
+ if span is None:
+ return {}
+ env = _parse_region(text[span[0] : span[1]], server_id)["env"]
+ return dict(env)
diff --git a/mureo/web/host_paths.py b/mureo/web/host_paths.py
index c0653ab..1e5196a 100644
--- a/mureo/web/host_paths.py
+++ b/mureo/web/host_paths.py
@@ -1,4 +1,4 @@
-"""Centralised filesystem paths for each supported Claude application host."""
+"""Centralised filesystem paths for each supported AI-agent host."""
from __future__ import annotations
@@ -6,7 +6,7 @@
from dataclasses import dataclass
from pathlib import Path
-SUPPORTED_HOSTS: tuple[str, ...] = ("claude-code", "claude-desktop")
+SUPPORTED_HOSTS: tuple[str, ...] = ("claude-code", "claude-desktop", "codex")
@dataclass(frozen=True)
@@ -80,6 +80,28 @@ def _claude_desktop_paths(home: Path) -> HostPaths:
)
+def _codex_paths(home: Path) -> HostPaths:
+ """Resolve OpenAI Codex CLI paths.
+
+ Codex reads MCP servers from ``~/.codex/config.toml`` (TOML, written
+ by :mod:`mureo.web.codex_mcp`) — so ``settings_path`` and
+ ``mcp_registry_path`` are the SAME file, unlike the Claude hosts. Skills
+ live under ``~/.codex/skills`` (Codex's own skill dir, not the shared
+ ``~/.claude/skills``); ``commands_dir`` is carried for signature
+ symmetry only (Codex surfaces workflows as skills, not commands).
+ ``credentials.json`` is the shared ``~/.mureo`` store.
+ """
+ config = home / ".codex" / "config.toml"
+ return HostPaths(
+ host="codex",
+ settings_path=config,
+ skills_dir=home / ".codex" / "skills",
+ commands_dir=home / ".codex" / "commands",
+ credentials_path=home / ".mureo" / "credentials.json",
+ mcp_registry_path=config,
+ )
+
+
def get_host_paths(host: str, home: Path | None = None) -> HostPaths:
"""Return the path bundle for ``host``.
@@ -91,4 +113,6 @@ def get_host_paths(host: str, home: Path | None = None) -> HostPaths:
resolved_home = home if home is not None else Path.home()
if host == "claude-code":
return _claude_code_paths(resolved_home)
+ if host == "codex":
+ return _codex_paths(resolved_home)
return _claude_desktop_paths(resolved_home)
diff --git a/mureo/web/session.py b/mureo/web/session.py
index e4ee89e..10fea3c 100644
--- a/mureo/web/session.py
+++ b/mureo/web/session.py
@@ -18,7 +18,7 @@
OAUTH_PROVIDERS: tuple[str, ...] = ("google", "meta")
# Allow-list of supported host slugs.
-SUPPORTED_HOSTS: tuple[str, ...] = ("claude-code", "claude-desktop")
+SUPPORTED_HOSTS: tuple[str, ...] = ("claude-code", "claude-desktop", "codex")
@dataclass
diff --git a/mureo/web/setup_actions.py b/mureo/web/setup_actions.py
index ae83e33..c4d74f4 100644
--- a/mureo/web/setup_actions.py
+++ b/mureo/web/setup_actions.py
@@ -26,6 +26,21 @@
remove_mcp_config,
)
from mureo.cli.setup_cmd import remove_skills
+from mureo.cli.setup_codex import (
+ install_codex_credential_guard,
+ remove_codex_credential_guard,
+)
+from mureo.web.codex_mcp import (
+ install_codex_mcp_block,
+ install_codex_server_block,
+ installed_codex_server_ids,
+ is_codex_server_installed,
+ remove_codex_mcp_block,
+ remove_codex_server_block,
+ resolve_codex_config_path,
+ set_mureo_disable_env_codex,
+ unset_mureo_disable_env_codex,
+)
from mureo.web.desktop_mcp import (
install_desktop_mcp_block,
install_desktop_server_block,
@@ -51,6 +66,7 @@
# tests keep the exact pre-change Claude Code behaviour.
_HOST_CODE = "claude-code"
_HOST_DESKTOP = "claude-desktop"
+_HOST_CODEX = "codex"
# Official MCP provider IDs that ``clear_all_setup`` will try to remove if
# they are present in ``settings.json``. Listed explicitly (rather than
@@ -99,6 +115,35 @@ def _install_desktop_mcp(home: Path | None) -> ActionResult:
return ActionResult(status="ok", detail=str(config_path))
+def _codex_hooks_path(home: Path | None) -> Path:
+ """``/.codex/hooks.json`` — Codex's PreToolUse hook file."""
+ return resolve_codex_config_path(home).parent / "hooks.json"
+
+
+def _codex_skills_dir(home: Path | None) -> Path:
+ """``/.codex/skills`` — Codex's own skill directory."""
+ from mureo.web.host_paths import get_host_paths
+
+ return get_host_paths(_HOST_CODEX, home).skills_dir
+
+
+def _install_codex_mcp(home: Path | None) -> ActionResult:
+ """Register the ``[mcp_servers.mureo]`` block in the Codex config (TOML)."""
+ try:
+ config_path = resolve_codex_config_path(home)
+ wrote = install_codex_mcp_block(
+ config_path, sys.executable, ["-m", "mureo.mcp"]
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.exception("install_mureo_mcp (codex) failed")
+ return ActionResult(status="error", detail=type(exc).__name__)
+
+ mark_part_installed(PART_MCP, home=home)
+ if not wrote:
+ return ActionResult(status="noop", detail="already_configured")
+ return ActionResult(status="ok", detail=str(config_path))
+
+
def backfill_disable_for_installed_providers(
host: str, home: Path | None = None
) -> None:
@@ -130,7 +175,6 @@ def backfill_disable_for_installed_providers(
"""
try:
from mureo.providers.catalog import get_catalog
- from mureo.providers.config_writer import is_provider_installed
from mureo.web.host_paths import get_host_paths
registry = get_host_paths(host, home).mcp_registry_path
@@ -147,7 +191,7 @@ def backfill_disable_for_installed_providers(
# native-toggle, which gate on the verified connector.
if spec.install_kind == "hosted_http":
continue
- if is_provider_installed(spec.id, settings_path=registry):
+ if _registered_in_registry(host, spec.id, registry):
_disable_native_for(host, platform, home, registry)
except Exception: # noqa: BLE001 — backfill must never break setup
logger.exception("backfill disable-env after mureo MCP install failed")
@@ -174,12 +218,31 @@ def _apply_disable_env(
resolve_desktop_config_path(home), env_var
)
return changed, True
+ if host == _HOST_CODEX:
+ env_var = "MUREO_DISABLE_" + platform.upper()
+ changed = set_mureo_disable_env_codex(resolve_codex_config_path(home), env_var)
+ return changed, True
from mureo.providers.mureo_env import set_mureo_disable_env
res = set_mureo_disable_env(platform, settings_path=registry) # type: ignore[arg-type]
return res.changed, res.mureo_block_present
+def _registered_in_registry(host: str, provider_id: str, registry: Path) -> bool:
+ """Host-aware "is ``provider_id`` registered in this host's MCP registry?".
+
+ Codex stores MCP servers as TOML ``[mcp_servers.]`` regions, so it is
+ probed via :func:`is_codex_server_installed`; the Claude hosts parse the
+ JSON ``mcpServers`` object of ``registry`` (``~/.claude.json`` for Code,
+ ``claude_desktop_config.json`` for Desktop).
+ """
+ if host == _HOST_CODEX:
+ return is_codex_server_installed(registry, provider_id)
+ from mureo.providers.config_writer import is_provider_installed
+
+ return is_provider_installed(provider_id, settings_path=registry)
+
+
def _disable_native_for(
host: str, platform: str, home: Path | None, registry: Path
) -> None:
@@ -228,10 +291,7 @@ def set_native_preference(
# prefer_official: guard — the official path must really be usable.
try:
from mureo.providers.catalog import get_catalog
- from mureo.providers.config_writer import (
- is_hosted_provider_connected,
- is_provider_installed,
- )
+ from mureo.providers.config_writer import is_hosted_provider_connected
spec = next(
(s for s in get_catalog() if s.coexists_with_mureo_platform == platform),
@@ -242,7 +302,7 @@ def set_native_preference(
if spec.install_kind == "hosted_http":
if not is_hosted_provider_connected(spec):
return ActionResult(status="error", detail="connector_not_connected")
- elif not is_provider_installed(spec.id, settings_path=registry):
+ elif not _registered_in_registry(host, spec.id, registry):
return ActionResult(status="error", detail="provider_not_installed")
except Exception as exc: # noqa: BLE001
logger.exception("set_native_preference guard failed")
@@ -277,6 +337,11 @@ def _restore_native(
changed = unset_mureo_disable_env_desktop(
resolve_desktop_config_path(home), env_var
)
+ elif host == _HOST_CODEX:
+ env_var = "MUREO_DISABLE_" + platform.upper()
+ changed = unset_mureo_disable_env_codex(
+ resolve_codex_config_path(home), env_var
+ )
else:
from mureo.providers.mureo_env import unset_mureo_disable_env
@@ -302,6 +367,8 @@ def install_mureo_mcp(home: Path | None = None, host: str = _HOST_CODE) -> Actio
"""
if host == _HOST_DESKTOP:
result = _install_desktop_mcp(home)
+ elif host == _HOST_CODEX:
+ result = _install_codex_mcp(home)
else:
try:
from mureo.auth_setup import install_mcp_config
@@ -328,15 +395,20 @@ def install_auth_hook(home: Path | None = None, host: str = _HOST_CODE) -> Actio
Claude Desktop has no ``hooks.PreToolUse`` surface, so the Desktop
branch is a graceful no-op that writes nothing and does NOT mark
- ``PART_HOOK`` installed (planner HANDOFF Q2).
+ ``PART_HOOK`` installed (planner HANDOFF Q2). Codex DOES have a
+ PreToolUse surface (``~/.codex/hooks.json``), so it installs the same
+ credential guard via the home-aware codex installer.
"""
if host == _HOST_DESKTOP:
return ActionResult(status="noop", detail="unsupported_on_desktop")
try:
- from mureo.auth_setup import install_credential_guard
+ if host == _HOST_CODEX:
+ result = install_codex_credential_guard(_codex_hooks_path(home))
+ else:
+ from mureo.auth_setup import install_credential_guard
- result = install_credential_guard()
+ result = install_credential_guard()
except Exception as exc: # noqa: BLE001
logger.exception("install_auth_hook failed")
return ActionResult(status="error", detail=type(exc).__name__)
@@ -352,16 +424,15 @@ def install_workflow_skills(
) -> ActionResult:
"""Copy workflow skills into ~/.claude/skills.
- Host-agnostic by design (planner HANDOFF Q3): both Claude Code and
- Claude Desktop share ``~/.claude/skills`` and there is no Desktop
- plugin bundle in the repo. The ``host`` param is accepted for
- signature symmetry only and intentionally does not branch.
+ Claude Code and Claude Desktop share ``~/.claude/skills`` (planner
+ HANDOFF Q3). Codex reads skills from its OWN ``~/.codex/skills``, so
+ the codex host installs there (home-aware) instead.
"""
- del host # host-agnostic; accepted for signature symmetry only
try:
from mureo.cli.setup_cmd import install_skills
- count, dest = install_skills()
+ target = _codex_skills_dir(home) if host == _HOST_CODEX else None
+ count, dest = install_skills(target_dir=target)
except Exception as exc: # noqa: BLE001
logger.exception("install_workflow_skills failed")
return ActionResult(status="error", detail=type(exc).__name__)
@@ -652,6 +723,64 @@ def _install_provider_desktop(
return ActionResult(status="ok", detail=spec.id)
+def _install_provider_codex(
+ provider_id: str,
+ home: Path | None,
+ credentials_path: Path | None = None,
+) -> ActionResult:
+ """Codex path — install one official provider into ``config.toml`` (TOML).
+
+ Mirrors the Desktop path: pipx/npm providers still run the
+ host-agnostic ``run_install`` subprocess and then write a tagged
+ ``[mcp_servers.]`` block (with credential env) via
+ :func:`install_codex_server_block`. hosted_http (Meta) has no Codex
+ connector — Codex cannot wire a remote MCP through ``config.toml`` and
+ has no account-level Connectors — so it is ``manual_required`` and
+ native is NOT auto-disabled (no stranding), identical to Code/Desktop.
+ ``credentials.json`` is never touched.
+ """
+ try:
+ from mureo.providers.catalog import get_provider
+ from mureo.providers.installer import run_install
+
+ spec = get_provider(provider_id)
+ except KeyError:
+ return ActionResult(status="error", detail="unknown_provider")
+ except Exception as exc: # noqa: BLE001
+ logger.exception("install_provider (codex) import/resolve failed")
+ return ActionResult(status="error", detail=type(exc).__name__)
+
+ if spec.install_kind == "hosted_http":
+ return ActionResult(status="manual_required", detail=spec.id)
+
+ if spec.install_argv:
+ try:
+ result = run_install(spec, dry_run=False)
+ except Exception as exc: # noqa: BLE001
+ logger.exception("install_provider (codex) subprocess failed")
+ return ActionResult(status="error", detail=type(exc).__name__)
+ if result.returncode != 0:
+ return ActionResult(
+ status="error",
+ detail=f"install_returncode_{result.returncode}",
+ )
+
+ block: dict[str, Any] = dict(_desktop_block_for(spec))
+ extra_env = _credential_env_for(spec, credentials_path)
+ if extra_env:
+ block = {**block, "env": {**block.get("env", {}), **extra_env}}
+ try:
+ config_path = resolve_codex_config_path(home)
+ wrote = install_codex_server_block(config_path, spec.id, block)
+ except Exception as exc: # noqa: BLE001
+ logger.exception("install_provider (codex) config write failed")
+ return ActionResult(status="error", detail=type(exc).__name__)
+
+ if not wrote:
+ return ActionResult(status="noop", detail="already_configured")
+ return ActionResult(status="ok", detail=spec.id)
+
+
def install_provider(
provider_id: str,
home: Path | None = None,
@@ -663,14 +792,17 @@ def install_provider(
``host="claude-code"`` (default) registers into ``~/.claude.json``;
``host="claude-desktop"`` writes the provider block into
- ``claude_desktop_config.json``. In BOTH cases the credential env the
- upstream MCP needs is resolved from ``credentials_path`` (defaults to
- ``~/.mureo/credentials.json``) and injected into the registered
- block — without it the official server registers but cannot
- authenticate.
+ ``claude_desktop_config.json``; ``host="codex"`` writes a tagged
+ ``[mcp_servers.]`` block into ``~/.codex/config.toml``. In all
+ cases the credential env the upstream MCP needs is resolved from
+ ``credentials_path`` (defaults to ``~/.mureo/credentials.json``) and
+ injected into the registered block — without it the official server
+ registers but cannot authenticate.
"""
if host == _HOST_DESKTOP:
return _install_provider_desktop(provider_id, home, credentials_path)
+ if host == _HOST_CODEX:
+ return _install_provider_codex(provider_id, home, credentials_path)
return _install_provider_code(provider_id, credentials_path)
@@ -721,6 +853,51 @@ def _remove_provider_desktop(provider_id: str, home: Path | None) -> ActionResul
return ActionResult(status="ok", detail=provider_id)
+def _remove_provider_codex(provider_id: str, home: Path | None) -> ActionResult:
+ """Codex path — remove a provider from ``config.toml``.
+
+ Mirror of :func:`_remove_provider_desktop`: hosted_http (never written
+ on Codex) re-enables mureo's own tool family by unsetting
+ ``MUREO_DISABLE_``; a local-install provider's tagged
+ ``[mcp_servers.]`` region is removed. Idempotent
+ (``noop not_registered``). ``credentials.json`` is never touched.
+ """
+ try:
+ from mureo.providers.catalog import get_provider
+
+ spec = get_provider(provider_id)
+ except KeyError:
+ return ActionResult(status="error", detail="unknown_provider")
+ except Exception as exc: # noqa: BLE001
+ logger.exception("remove_provider (codex) import/resolve failed")
+ return ActionResult(status="error", detail=type(exc).__name__)
+
+ config_path = resolve_codex_config_path(home)
+ if spec.install_kind == "hosted_http":
+ platform = spec.coexists_with_mureo_platform
+ if not platform:
+ return ActionResult(status="noop", detail="not_registered")
+ env_var = "MUREO_DISABLE_" + platform.upper()
+ try:
+ changed = unset_mureo_disable_env_codex(config_path, env_var)
+ except Exception as exc: # noqa: BLE001
+ logger.exception("remove_provider (codex) unset-env failed")
+ return ActionResult(status="error", detail=type(exc).__name__)
+ if not changed:
+ return ActionResult(status="noop", detail="not_registered")
+ return ActionResult(status="ok", detail=provider_id)
+
+ try:
+ removed = remove_codex_server_block(config_path, provider_id)
+ except Exception as exc: # noqa: BLE001
+ logger.exception("remove_provider (codex) failed")
+ return ActionResult(status="error", detail=type(exc).__name__)
+
+ if not removed:
+ return ActionResult(status="noop", detail="not_registered")
+ return ActionResult(status="ok", detail=provider_id)
+
+
def remove_provider(
provider_id: str, home: Path | None = None, host: str = _HOST_CODE
) -> ActionResult:
@@ -729,10 +906,13 @@ def remove_provider(
``host="claude-code"`` (default) unregisters it from user scope via
the ``claude`` CLI (``~/.claude.json``), NOT ``settings.json``.
``host="claude-desktop"`` pops only ``mcpServers[provider_id]`` from
- ``claude_desktop_config.json``.
+ ``claude_desktop_config.json``; ``host="codex"`` removes the tagged
+ ``[mcp_servers.]`` region from ``~/.codex/config.toml``.
"""
if host == _HOST_DESKTOP:
return _remove_provider_desktop(provider_id, home)
+ if host == _HOST_CODEX:
+ return _remove_provider_codex(provider_id, home)
try:
from mureo.providers.catalog import get_provider
@@ -813,8 +993,8 @@ def confirm_hosted_provider(
return ActionResult(status="noop", detail="nothing_to_switch")
try:
- if host == _HOST_DESKTOP:
- # No `claude mcp list` on Desktop ⇒ never auto-verifiable.
+ if host in (_HOST_DESKTOP, _HOST_CODEX):
+ # No `claude mcp list` on Desktop/Codex ⇒ never auto-verifiable.
# Apply only on an explicit user affirmation (no stranding).
if not affirm:
return ActionResult(status="manual", detail=spec.id)
@@ -842,6 +1022,12 @@ def confirm_hosted_provider(
resolve_desktop_config_path(home), env_var
)
mureo_block_present = True # Desktop has no such signal
+ elif host == _HOST_CODEX:
+ env_var = "MUREO_DISABLE_" + platform.upper()
+ changed = set_mureo_disable_env_codex(
+ resolve_codex_config_path(home), env_var
+ )
+ mureo_block_present = True # Codex has no such signal
else:
from mureo.providers.mureo_env import set_mureo_disable_env
@@ -919,6 +1105,16 @@ def remove_mureo_mcp(home: Path | None = None, host: str = _HOST_CODE) -> Action
"""
if host == _HOST_DESKTOP:
return _remove_desktop_mcp(home)
+ if host == _HOST_CODEX:
+ try:
+ changed = remove_codex_mcp_block(resolve_codex_config_path(home))
+ except Exception as exc: # noqa: BLE001
+ logger.exception("remove_mureo_mcp (codex) failed")
+ return ActionResult(status="error", detail=type(exc).__name__)
+ if not changed:
+ return ActionResult(status="noop", detail="not_installed")
+ clear_part(PART_MCP, home=home)
+ return ActionResult(status="ok")
try:
result = remove_mcp_config()
@@ -937,12 +1133,19 @@ def remove_auth_hook(home: Path | None = None, host: str = _HOST_CODE) -> Action
Mirror of ``install_auth_hook``: the Desktop branch is a no-op
(Desktop has no hook surface) that touches no file and does NOT
- clear ``PART_HOOK`` state (planner HANDOFF Q2).
+ clear ``PART_HOOK`` state (planner HANDOFF Q2). Codex has a hook
+ surface, so it drops the tagged entries from ``~/.codex/hooks.json``.
"""
if host == _HOST_DESKTOP:
return ActionResult(status="noop", detail="unsupported_on_desktop")
try:
+ if host == _HOST_CODEX:
+ removed = remove_codex_credential_guard(_codex_hooks_path(home))
+ if removed is None:
+ return ActionResult(status="noop", detail="not_installed")
+ clear_part(PART_HOOK, home=home)
+ return ActionResult(status="ok")
result = remove_credential_guard()
except Exception as exc: # noqa: BLE001
logger.exception("remove_auth_hook failed")
@@ -959,13 +1162,12 @@ def remove_workflow_skills(
) -> ActionResult:
"""Delete bundle-listed workflow skills from ``~/.claude/skills``.
- Host-agnostic (planner HANDOFF Q3): skills live in the shared
- ``~/.claude/skills`` for both hosts. ``host`` is accepted for
- signature symmetry only and intentionally does not branch.
+ Claude Code and Desktop share ``~/.claude/skills`` (planner HANDOFF
+ Q3); Codex removes from its own ``~/.codex/skills``.
"""
- del host # host-agnostic; accepted for signature symmetry only
try:
- count, dest = remove_skills()
+ target = _codex_skills_dir(home) if host == _HOST_CODEX else None
+ count, dest = remove_skills(target_dir=target)
except Exception as exc: # noqa: BLE001
logger.exception("remove_workflow_skills failed")
return ActionResult(status="error", detail=type(exc).__name__)
@@ -998,6 +1200,10 @@ def _installed_official_providers(
``claude_desktop_config.json`` so that file is read directly. A
missing/malformed file degrades gracefully to "none".
"""
+ if host == _HOST_CODEX:
+ installed = installed_codex_server_ids(resolve_codex_config_path(home))
+ return [pid for pid in _OFFICIAL_PROVIDER_IDS if pid in installed]
+
if host != _HOST_DESKTOP:
from mureo.providers.config_writer import is_provider_installed
@@ -1047,7 +1253,9 @@ def clear_all_setup(home: Path | None = None, host: str = _HOST_CODE) -> dict[st
envelope: dict[str, Any] = {}
envelope["mureo_mcp"] = _safe_step(remove_mureo_mcp, home=home, host=host)
envelope["auth_hook"] = _safe_step(remove_auth_hook, home=home, host=host)
- envelope["skills"] = _safe_step(remove_workflow_skills, home=home)
+ # Forward host: codex skills live in ~/.codex/skills, not ~/.claude/skills,
+ # so bulk-clear must target the right directory (install already does).
+ envelope["skills"] = _safe_step(remove_workflow_skills, home=home, host=host)
commands_dir = (home or Path.home()) / ".claude" / "commands"
try:
diff --git a/mureo/web/status_collector.py b/mureo/web/status_collector.py
index db8d5f9..747cccc 100644
--- a/mureo/web/status_collector.py
+++ b/mureo/web/status_collector.py
@@ -91,10 +91,16 @@ def _detect_installed_providers(mcp_registry_path: Path) -> dict[str, bool]:
Reads the file the host actually discovers MCP servers from
(``~/.claude.json`` for Claude Code — NOT ``settings.json`` —;
- ``claude_desktop_config.json`` for Desktop). A read-only parse is
- deterministic and race-safe for status display; writes still go
- through the ``claude`` CLI.
+ ``claude_desktop_config.json`` for Desktop; ``config.toml`` for Codex).
+ A read-only parse is deterministic and race-safe for status display.
"""
+ if mcp_registry_path.suffix == ".toml":
+ from mureo.web.codex_mcp import installed_codex_server_ids
+
+ ids = installed_codex_server_ids(mcp_registry_path)
+ installed = {pid: pid in ids for pid in OFFICIAL_PROVIDER_IDS}
+ installed[MUREO_NATIVE_ID] = MUREO_NATIVE_ID in ids
+ return installed
payload = read_json_safe(mcp_registry_path)
raw = payload.get("mcpServers")
mcp_servers: dict[str, Any] = raw if isinstance(raw, dict) else {}
@@ -116,6 +122,14 @@ def _detect_mureo_disable(mcp_registry_path: Path) -> dict[str, bool]:
missing/corrupt file or absent mureo block means nothing is
disabled (all ``False``) — never raises.
"""
+ if mcp_registry_path.suffix == ".toml":
+ from mureo.web.codex_mcp import read_codex_server_env
+
+ codex_env = read_codex_server_env(mcp_registry_path, MUREO_NATIVE_ID)
+ return {
+ p: codex_env.get("MUREO_DISABLE_" + p.upper()) == "1"
+ for p in _DISABLE_PLATFORMS
+ }
payload = read_json_safe(mcp_registry_path)
servers = payload.get("mcpServers")
mureo = servers.get("mureo") if isinstance(servers, dict) else None
diff --git a/tests/test_setup_codex.py b/tests/test_setup_codex.py
index e283f45..12150fe 100644
--- a/tests/test_setup_codex.py
+++ b/tests/test_setup_codex.py
@@ -234,3 +234,49 @@ def test_replaces_symlinked_operational_skill(
assert (link / "SKILL.md").exists()
assert external.exists()
assert (external / "SKILL.md").read_text() == "dev onboard body"
+
+
+class TestRemoveCredentialGuard:
+ """remove_codex_credential_guard — inverse of the install, tag-scoped."""
+
+ def test_removes_only_tagged_entries(self, home: Path) -> None:
+ from mureo.cli.setup_codex import (
+ remove_codex_credential_guard,
+ )
+
+ hooks_file = home / ".codex" / "hooks.json"
+ hooks_file.parent.mkdir(parents=True)
+ # A user's own unrelated hook must survive.
+ hooks_file.write_text(
+ json.dumps(
+ {"PreToolUse": [{"matcher": "Read", "hooks": [{"command": "echo hi"}]}]}
+ ),
+ encoding="utf-8",
+ )
+ install_codex_credential_guard()
+ removed = remove_codex_credential_guard()
+ assert removed == hooks_file
+ data = json.loads(hooks_file.read_text(encoding="utf-8"))
+ remaining = [
+ h.get("command", "")
+ for e in data["PreToolUse"]
+ for h in e.get("hooks", [])
+ ]
+ assert remaining == ["echo hi"] # only the user's hook kept
+ assert not any("mureo-credential-guard" in c for c in remaining)
+
+ def test_idempotent_when_absent(self, home: Path) -> None:
+ from mureo.cli.setup_codex import remove_codex_credential_guard
+
+ assert remove_codex_credential_guard() is None # no file
+
+ def test_honours_explicit_hooks_file_path(self, tmp_path: Path) -> None:
+ """The home-aware override path is respected (configure-UI flow)."""
+ from mureo.cli.setup_codex import remove_codex_credential_guard
+
+ target = tmp_path / "custom" / "hooks.json"
+ install_codex_credential_guard(target)
+ assert target.exists()
+ assert remove_codex_credential_guard(target) == target
+ data = json.loads(target.read_text(encoding="utf-8"))
+ assert data["PreToolUse"] == []
diff --git a/tests/test_web_codex_mcp.py b/tests/test_web_codex_mcp.py
new file mode 100644
index 0000000..3760d07
--- /dev/null
+++ b/tests/test_web_codex_mcp.py
@@ -0,0 +1,205 @@
+"""Tests for ``mureo.web.codex_mcp`` — the Codex ``config.toml`` writer.
+
+Codex stores MCP servers as TOML; this module manages tagged
+``[mcp_servers.]`` regions surgically, preserving every other byte of
+the operator's file (mirrors the JSON ``desktop_mcp`` surface for parity).
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from mureo.web import codex_mcp as cx
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+
+def _cfg(tmp_path: Path) -> Path:
+ return tmp_path / ".codex" / "config.toml"
+
+
+@pytest.mark.unit
+def test_resolve_config_path_is_home_aware(tmp_path: Path) -> None:
+ assert cx.resolve_codex_config_path(tmp_path) == tmp_path / ".codex" / "config.toml"
+
+
+@pytest.mark.unit
+def test_install_mcp_block_then_idempotent(tmp_path: Path) -> None:
+ cfg = _cfg(tmp_path)
+ assert cx.install_codex_mcp_block(cfg, "python", ["-m", "mureo.mcp"]) is True
+ text = cfg.read_text()
+ assert "[mcp_servers.mureo]" in text
+ assert 'args = ["-m", "mureo.mcp"]' in text
+ # Presence-based idempotency: a second install is a no-op.
+ assert cx.install_codex_mcp_block(cfg, "python", ["-m", "mureo.mcp"]) is False
+
+
+@pytest.mark.unit
+def test_install_server_block_preserves_user_content(tmp_path: Path) -> None:
+ cfg = _cfg(tmp_path)
+ cfg.parent.mkdir(parents=True)
+ cfg.write_text(
+ '# my notes\n[mcp_servers.other]\ncommand = "foo"\n', encoding="utf-8"
+ )
+ cx.install_codex_server_block(
+ cfg, "google-ads-official", {"command": "uvx", "args": ["x"], "env": {"K": "v"}}
+ )
+ text = cfg.read_text()
+ assert "# my notes" in text
+ assert "[mcp_servers.other]" in text # untouched
+ assert "[mcp_servers.google-ads-official]" in text
+ assert "[mcp_servers.google-ads-official.env]" in text
+ assert 'K = "v"' in text
+
+
+@pytest.mark.unit
+def test_install_server_block_idempotent_on_identical(tmp_path: Path) -> None:
+ cfg = _cfg(tmp_path)
+ block = {"command": "uvx", "args": ["a"], "env": {"K": "v"}}
+ assert cx.install_codex_server_block(cfg, "p", block) is True
+ assert cx.install_codex_server_block(cfg, "p", dict(block)) is False
+
+
+@pytest.mark.unit
+def test_install_server_block_replaces_changed(tmp_path: Path) -> None:
+ cfg = _cfg(tmp_path)
+ cx.install_codex_server_block(cfg, "p", {"command": "old", "args": []})
+ assert (
+ cx.install_codex_server_block(cfg, "p", {"command": "new", "args": []}) is True
+ )
+ text = cfg.read_text()
+ assert 'command = "new"' in text
+ assert "old" not in text
+ # Exactly one region for "p".
+ assert text.count("# >>> mureo-mcp:p >>>") == 1
+
+
+@pytest.mark.unit
+def test_untagged_block_raises_conflict(tmp_path: Path) -> None:
+ cfg = _cfg(tmp_path)
+ cfg.parent.mkdir(parents=True)
+ cfg.write_text('[mcp_servers.mureo]\ncommand = "x"\n', encoding="utf-8")
+ with pytest.raises(cx.CodexConfigConflictError):
+ cx.install_codex_server_block(cfg, "mureo", {"command": "y", "args": []})
+
+
+@pytest.mark.unit
+def test_untagged_subtable_not_a_conflict(tmp_path: Path) -> None:
+ """``[mcp_servers.mureo.env]`` is a sub-table, not the block header —
+ it must not be mistaken for an untagged conflicting block."""
+ cfg = _cfg(tmp_path)
+ cx.install_codex_mcp_block(cfg, "python", ["-m", "mureo.mcp"])
+ # Re-render via set-env touches the sub-table; no conflict raised.
+ assert cx.set_mureo_disable_env_codex(cfg, "MUREO_DISABLE_GOOGLE") is True
+
+
+@pytest.mark.unit
+def test_set_and_unset_disable_env_preserve_others(tmp_path: Path) -> None:
+ cfg = _cfg(tmp_path)
+ cx.install_codex_mcp_block(cfg, "python", ["-m", "mureo.mcp"])
+ assert cx.set_mureo_disable_env_codex(cfg, "MUREO_DISABLE_GOOGLE") is True
+ assert cx.set_mureo_disable_env_codex(cfg, "MUREO_DISABLE_GOOGLE") is False # idem
+ assert cx.set_mureo_disable_env_codex(cfg, "MUREO_DISABLE_META") is True
+ env = cx.read_codex_server_env(cfg, "mureo")
+ assert env == {"MUREO_DISABLE_GOOGLE": "1", "MUREO_DISABLE_META": "1"}
+ # command/args survive the env re-render.
+ assert 'args = ["-m", "mureo.mcp"]' in cfg.read_text()
+ # Unsetting one keeps the other.
+ assert cx.unset_mureo_disable_env_codex(cfg, "MUREO_DISABLE_META") is True
+ assert cx.read_codex_server_env(cfg, "mureo") == {"MUREO_DISABLE_GOOGLE": "1"}
+
+
+@pytest.mark.unit
+def test_set_disable_env_noop_without_mureo_block(tmp_path: Path) -> None:
+ cfg = _cfg(tmp_path)
+ cfg.parent.mkdir(parents=True)
+ cfg.write_text("# empty\n", encoding="utf-8")
+ assert cx.set_mureo_disable_env_codex(cfg, "MUREO_DISABLE_GOOGLE") is False
+
+
+@pytest.mark.unit
+def test_remove_block_idempotent_and_preserves_user_content(tmp_path: Path) -> None:
+ cfg = _cfg(tmp_path)
+ cfg.parent.mkdir(parents=True)
+ cfg.write_text("# keep me\n", encoding="utf-8")
+ cx.install_codex_mcp_block(cfg, "python", ["-m", "mureo.mcp"])
+ assert cx.remove_codex_mcp_block(cfg) is True
+ assert cx.remove_codex_mcp_block(cfg) is False # idempotent
+ assert "# keep me" in cfg.read_text()
+ assert "mcp_servers.mureo" not in cfg.read_text()
+
+
+@pytest.mark.unit
+def test_installed_ids_and_is_installed(tmp_path: Path) -> None:
+ cfg = _cfg(tmp_path)
+ assert cx.installed_codex_server_ids(cfg) == set() # missing file
+ cx.install_codex_mcp_block(cfg, "python", ["-m", "mureo.mcp"])
+ cx.install_codex_server_block(cfg, "ga4-official", {"command": "g", "args": []})
+ assert cx.installed_codex_server_ids(cfg) == {"mureo", "ga4-official"}
+ assert cx.is_codex_server_installed(cfg, "ga4-official") is True
+ assert cx.is_codex_server_installed(cfg, "absent") is False
+
+
+@pytest.mark.unit
+def test_toml_string_escaping_roundtrips(tmp_path: Path) -> None:
+ """A value with quotes/backslashes survives render → parse."""
+ cfg = _cfg(tmp_path)
+ cx.install_codex_mcp_block(cfg, 'C:\\Program Files\\py "x"', ["-m", "mureo.mcp"])
+ # The mureo block parses back to the exact command via read of args/env
+ # path (command parsing is exercised indirectly by the env re-render
+ # preserving it). Assert the rendered TOML escaped the quotes/backslash.
+ text = cfg.read_text()
+ assert '\\"x\\"' in text
+ assert "\\\\" in text
+
+
+@pytest.mark.unit
+def test_windows_command_survives_env_retoggle(tmp_path: Path) -> None:
+ """Regression: a backslash command (Windows ``sys.executable``) must NOT
+ be corrupted when the mureo region is re-rendered by an env toggle —
+ chained-replace unescaping used to turn ``C:\\nina`` into ``C:ina``,
+ producing invalid TOML that breaks the whole file."""
+ cfg = _cfg(tmp_path)
+ win_cmd = r"C:\Users\nina\Programs\python.exe"
+ cx.install_codex_mcp_block(cfg, win_cmd, ["-m", "mureo.mcp"])
+ cx.set_mureo_disable_env_codex(cfg, "MUREO_DISABLE_GOOGLE")
+ text = cfg.read_text()
+ # The command line must not contain a raw newline (which would be
+ # invalid TOML for a basic string).
+ command_line = next(ln for ln in text.splitlines() if ln.startswith("command = "))
+ assert "\n" not in command_line # trivially true post-split; documents intent
+ span = cx._region_span(text, "mureo")
+ assert span is not None
+ block = cx._parse_region(text[span[0] : span[1]], "mureo")
+ assert block["command"] == win_cmd # round-trip lossless
+ assert block["args"] == ["-m", "mureo.mcp"]
+
+
+@pytest.mark.unit
+def test_remove_preserves_operator_blank_lines_elsewhere(tmp_path: Path) -> None:
+ """Region removal tidies only its own seam — an operator's intentional
+ multi-blank-line spacing elsewhere is preserved verbatim."""
+ cfg = _cfg(tmp_path)
+ cfg.parent.mkdir(parents=True)
+ cfg.write_text(
+ "[a]\nx = 1\n\n\n\n[b]\ny = 2\n", encoding="utf-8"
+ ) # 3 blank lines between [a] and [b]
+ cx.install_codex_mcp_block(cfg, "python", ["-m", "mureo.mcp"]) # appended at end
+ assert cx.remove_codex_mcp_block(cfg) is True
+ # The operator's 3-blank-line gap is untouched.
+ assert "[a]\nx = 1\n\n\n\n[b]\ny = 2\n" in cfg.read_text()
+
+
+@pytest.mark.unit
+def test_remove_middle_region_collapses_only_its_seam(tmp_path: Path) -> None:
+ cfg = _cfg(tmp_path)
+ cx.install_codex_mcp_block(cfg, "python", ["-m", "mureo.mcp"])
+ cx.install_codex_server_block(cfg, "ga4-official", {"command": "g", "args": []})
+ # Remove the FIRST (now middle) region.
+ cx.remove_codex_mcp_block(cfg)
+ text = cfg.read_text()
+ assert "\n\n\n" not in text # no 3+ newline run left at the seam
+ assert "[mcp_servers.ga4-official]" in text # the other region intact
diff --git a/tests/test_web_host_paths.py b/tests/test_web_host_paths.py
index 567677a..e67ef3e 100644
--- a/tests/test_web_host_paths.py
+++ b/tests/test_web_host_paths.py
@@ -25,8 +25,8 @@ class TestSupportedHosts:
def test_supported_hosts_is_tuple(self) -> None:
assert isinstance(SUPPORTED_HOSTS, tuple)
- def test_only_claude_code_and_desktop(self) -> None:
- assert set(SUPPORTED_HOSTS) == {"claude-code", "claude-desktop"}
+ def test_supported_hosts_allow_list(self) -> None:
+ assert set(SUPPORTED_HOSTS) == {"claude-code", "claude-desktop", "codex"}
@pytest.mark.unit
@@ -136,6 +136,21 @@ def test_linux_falls_back_to_dot_claude_settings(self, tmp_path: Path) -> None:
assert paths.settings_path == tmp_path / ".claude" / "settings.json"
+@pytest.mark.unit
+class TestGetHostPathsCodex:
+ def test_codex_paths(self, tmp_path: Path) -> None:
+ paths = get_host_paths("codex", home=tmp_path)
+ config = tmp_path / ".codex" / "config.toml"
+ assert paths.host == "codex"
+ # Codex reads MCP from the SAME config.toml it is configured in.
+ assert paths.settings_path == config
+ assert paths.mcp_registry_path == config
+ assert paths.skills_dir == tmp_path / ".codex" / "skills"
+ assert paths.commands_dir == tmp_path / ".codex" / "commands"
+ # Credentials stay in the mureo-owned store, not under ~/.codex.
+ assert paths.credentials_path == tmp_path / ".mureo" / "credentials.json"
+
+
@pytest.mark.unit
class TestGetHostPathsRejectsUnknownHost:
@pytest.mark.parametrize(
diff --git a/tests/test_web_setup_actions_codex.py b/tests/test_web_setup_actions_codex.py
new file mode 100644
index 0000000..38629df
--- /dev/null
+++ b/tests/test_web_setup_actions_codex.py
@@ -0,0 +1,184 @@
+"""Integration tests for the ``host="codex"`` branches of setup_actions.
+
+Codex has full host parity with the Claude hosts: basic setup (MCP / hook /
+skills), official-provider install/remove into ``~/.codex/config.toml``
+(TOML), the native↔official disable toggle, status detection, and bulk
+clear. The provider install subprocess (``run_install``) is stubbed so these
+tests never touch the network or a real pipx/npm.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+from mureo.web import setup_actions as sa
+from mureo.web.status_collector import collect_status
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+
+class _OkInstall:
+ returncode = 0
+
+
+@pytest.fixture
+def _stub_run_install(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(
+ "mureo.providers.installer.run_install",
+ lambda spec, dry_run=False: _OkInstall(),
+ )
+
+
+@pytest.fixture
+def home(tmp_path: Path) -> Path:
+ (tmp_path / ".mureo").mkdir()
+ return tmp_path
+
+
+def _config(home: Path) -> Path:
+ return home / ".codex" / "config.toml"
+
+
+@pytest.mark.unit
+def test_basic_setup_writes_codex_files(home: Path) -> None:
+ assert sa.install_mureo_mcp(home=home, host="codex").status == "ok"
+ assert sa.install_mureo_mcp(home=home, host="codex").status == "noop"
+ assert "[mcp_servers.mureo]" in _config(home).read_text()
+
+ assert sa.install_auth_hook(home=home, host="codex").status == "ok"
+ hooks = home / ".codex" / "hooks.json"
+ assert hooks.exists() and "mureo-credential-guard" in hooks.read_text()
+
+ res = sa.install_workflow_skills(home=home, host="codex")
+ assert res.status == "ok"
+ assert (home / ".codex" / "skills").exists()
+ # Skills land under ~/.codex, NOT the shared ~/.claude.
+ assert not (home / ".claude" / "skills").exists()
+
+
+@pytest.mark.unit
+def test_install_provider_codex_writes_tagged_block(
+ home: Path, _stub_run_install: None
+) -> None:
+ res = sa.install_provider("google-ads-official", home=home, host="codex")
+ assert res.status == "ok"
+ text = _config(home).read_text()
+ assert "[mcp_servers.google-ads-official]" in text
+ assert "# >>> mureo-mcp:google-ads-official >>>" in text
+ # Idempotent re-install.
+ assert sa.install_provider(
+ "google-ads-official", home=home, host="codex"
+ ).status == ("noop")
+
+
+@pytest.mark.unit
+def test_install_hosted_provider_is_manual_required(home: Path) -> None:
+ res = sa.install_provider("meta-ads-official", home=home, host="codex")
+ assert res.status == "manual_required"
+ # Nothing written for a hosted provider.
+ assert (
+ not _config(home).exists()
+ or "meta-ads-official" not in _config(home).read_text()
+ )
+
+
+@pytest.mark.unit
+def test_native_toggle_guard_and_apply(home: Path, _stub_run_install: None) -> None:
+ # Guard: cannot prefer official before the provider is installed.
+ assert (
+ sa.set_native_preference("google_ads", True, home=home, host="codex").detail
+ == "provider_not_installed"
+ )
+ sa.install_mureo_mcp(home=home, host="codex")
+ sa.install_provider("google-ads-official", home=home, host="codex")
+ # Now allowed → disable env set in the TOML mureo block.
+ assert (
+ sa.set_native_preference("google_ads", True, home=home, host="codex").status
+ == "ok"
+ )
+ disable = collect_status("codex", home=home).as_dict()["mureo_disable"]
+ assert disable["google_ads"] is True
+ # Restore native — always allowed.
+ assert (
+ sa.set_native_preference("google_ads", False, home=home, host="codex").status
+ == "ok"
+ )
+ disable = collect_status("codex", home=home).as_dict()["mureo_disable"]
+ assert disable["google_ads"] is False
+
+
+@pytest.mark.unit
+def test_backfill_disables_provider_installed_before_mcp(
+ home: Path, _stub_run_install: None
+) -> None:
+ # Provider registered FIRST (no mureo block yet) ...
+ sa.install_provider("google-ads-official", home=home, host="codex")
+ # ... then the mureo MCP — backfill must set the disable env.
+ sa.install_mureo_mcp(home=home, host="codex")
+ disable = collect_status("codex", home=home).as_dict()["mureo_disable"]
+ assert disable["google_ads"] is True
+
+
+@pytest.mark.unit
+def test_status_detects_codex_installed_providers(
+ home: Path, _stub_run_install: None
+) -> None:
+ sa.install_mureo_mcp(home=home, host="codex")
+ sa.install_provider("google-ads-official", home=home, host="codex")
+ installed = sa._installed_official_providers(home, host="codex")
+ assert "google-ads-official" in installed
+
+
+@pytest.mark.unit
+def test_remove_provider_and_clear_all(home: Path, _stub_run_install: None) -> None:
+ sa.install_mureo_mcp(home=home, host="codex")
+ sa.install_auth_hook(home=home, host="codex")
+ sa.install_provider("google-ads-official", home=home, host="codex")
+
+ assert sa.remove_provider(
+ "google-ads-official", home=home, host="codex"
+ ).status == ("ok")
+ assert sa.remove_provider(
+ "google-ads-official", home=home, host="codex"
+ ).status == ("noop")
+
+ # Re-install then bulk clear removes the mureo block + hook + provider.
+ sa.install_provider("google-ads-official", home=home, host="codex")
+ env = sa.clear_all_setup(home=home, host="codex")
+ assert env["mureo_mcp"]["status"] == "ok"
+ assert env["auth_hook"]["status"] == "ok"
+ assert "google-ads-official" in env["providers"]
+ text = _config(home).read_text() if _config(home).exists() else ""
+ assert "mcp_servers" not in text # everything mureo-managed is gone
+
+
+@pytest.mark.unit
+def test_remove_mureo_mcp_and_auth_hook_codex(home: Path) -> None:
+ sa.install_mureo_mcp(home=home, host="codex")
+ sa.install_auth_hook(home=home, host="codex")
+ assert sa.remove_mureo_mcp(home=home, host="codex").status == "ok"
+ assert sa.remove_mureo_mcp(home=home, host="codex").status == "noop"
+ assert sa.remove_auth_hook(home=home, host="codex").status == "ok"
+ assert sa.remove_auth_hook(home=home, host="codex").status == "noop"
+
+
+@pytest.mark.unit
+def test_clear_all_removes_codex_skills_not_claude(home: Path) -> None:
+ """Regression: clear_all must forward host=codex to the skills remover so
+ it targets ~/.codex/skills, not the shared ~/.claude/skills."""
+ sa.install_workflow_skills(home=home, host="codex")
+ codex_skills = home / ".codex" / "skills"
+ assert any(codex_skills.iterdir()) # something installed
+
+ sa.clear_all_setup(home=home, host="codex")
+ # The codex skill dirs the bundle owns are gone.
+ from mureo.cli.setup_cmd import _get_data_path
+
+ bundled = {p.name for p in _get_data_path("skills").iterdir() if p.is_dir()}
+ remaining = (
+ {p.name for p in codex_skills.iterdir()} if codex_skills.exists() else set()
+ )
+ assert not (bundled & remaining)