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)