#!/usr/bin/env python3
"""Pool-level MCP server registry: one document, reconciled into every account.

WHY (2026-09-22). Claude Code and Codex keep their MCP servers inside the config
dir — `<CLAUDE_CONFIG_DIR>/.claude.json` (user scope, plus a per-project entry
under `projects[<cwd>]`) and `<CODEX_HOME>/config.toml` (`[mcp_servers.<name>]`).
Under the pool every launch runs in a different config dir, so a stock
`claude mcp add` (or `npx appinspire-mcp install`) lands in ONE random account:
the next session, picked on another account, has no such server, and the one
after that connects an older copy somebody registered months ago. That is how
`appinspire-mcp` was "not connecting in every session".

The fix is one registry per pool root that is authoritative for the servers it
names and is reconciled into every account dir:

  ROOT/mcp-servers.json        the operator's registry — SYNCED to the server and
                               every manifest peer like the manifest itself
  ROOT/mcp-servers.local.json  a machine-local overlay other software on this
                               machine (app-robot's runner) publishes into, never
                               synced; the synced registry wins on any conflict.
                               A REPLICA pool (sync-role = replica) also keeps its
                               own adds/removes/learned changes here, under the
                               owner `local`: the source's next push would
                               overwrite its registry, but never this file.
  <acct>/.mcp-applied          a one-line stamp of what the last reconcile saw,
                               so the shims can skip python when nothing moved

Reconcile rules, the same for both providers:
  * every registry server is upserted into the account under its own name;
  * a name in `retired` (the tombstone an explicit `mcp remove` leaves behind)
    is deleted EVERYWHERE — user scope and every project entry — even if the
    registry never managed it, which is what "we do not use adspower-local-api
    any more" needs to hold fleet-wide; a name in `retiredUser` (a learned stock
    `claude mcp remove -s user`, or `mcp remove --user-only`) leaves project
    entries alone, exactly as the client itself did;
  * anything else the account holds is left alone (an account may carry its own
    extras);
  * project-scoped entries (`projects` in the registry) exist for claude only:
    Codex 0.156 reads no per-project config at all.

Both providers speak the same block shape — Claude Code's own (`type`, `command`,
`args`, `env` / `url`, `headers`) — and the codex side is a translation of it
(`KEY=${KEY}` env references become `env_vars`, headers become `http_headers` /
`env_http_headers` / `bearer_token_env_var`, a stdio server gets
`startup_timeout_sec` 60 so a cold `npx` download does not trip codex's 10 s
default). Codex-only keys ride along in the registry (`startup_timeout_sec`,
`env_vars`, … and a `codex` passthrough dict for anything else codex wrote, such
as an `oauth` sub-table) and are stripped for claude.

The claude path runs on stock macOS python (3.9). The codex path needs tomllib
(3.11+) and re-executes itself on an installed newer interpreter through
lib/codex_python.py — the same rule codex_settings.py already lives by.

Exit codes: 0 done; 1 refused (nothing saved); 2 usage; 3 the registry WAS saved
but some account failed to reconcile (details on stderr, `apply` repairs it).
"""
from __future__ import annotations

import argparse
import copy
import datetime as _dt
import json
import math
import os
import re
import shlex
import sys
import tempfile
import time

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

REGISTRY_FILE = "mcp-servers.json"
OVERLAY_FILE = "mcp-servers.local.json"
STAMP_FILE = ".mcp-applied"
LOCK_DIR = os.path.join(".locks", "mcp")
PROVIDERS = ("claude", "codex")
CONFIG_FILE = {"claude": ".claude.json", "codex": "config.toml"}
TRANSPORTS = ("stdio", "http", "sse", "ws")
#: Keys a codex block may carry that Claude Code must never see in .claude.json.
CODEX_ONLY_KEYS = frozenset({"startup_timeout_sec", "tool_timeout_sec", "env_vars",
                             "enabled", "cwd", "http_headers", "env_http_headers",
                             "bearer_token_env_var", "codex"})
#: The codex table keys this module models; everything else codex writes round-trips
#: through the block's `codex` passthrough dict untouched.
CODEX_MODELLED_KEYS = frozenset({"command", "args", "env", "env_vars", "url", "http_headers",
                                 "env_http_headers", "bearer_token_env_var",
                                 "startup_timeout_sec", "tool_timeout_sec", "cwd", "enabled"})
#: A cold `npx -y <pkg>` resolves and downloads before the server answers
#: `initialize`; codex gives a server 10 s by default and then reports it dead
#: for the whole session. Claude Code waits longer on its own.
DEFAULT_STARTUP_TIMEOUT = 60
MAX_TIMEOUT = 86400
ENV_REF = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)(:-[^}]*)?\}$")
BEARER_REF = re.compile(r"^Bearer \$\{([A-Za-z_][A-Za-z0-9_]*)\}$")
ACCT_ID = re.compile(r"^acct-\d{2,4}$")
BARE_KEY = re.compile(r"^[A-Za-z0-9_-]+$")
#: Claude Code's own rule for a server name (`claude mcp add` refuses anything else).
STRICT_NAME = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
#: The keys Claude Code creates for a project entry the first time a session
#: opens that directory. A registry-created entry carries the same skeleton so
#: the client finds every field it expects.
PROJECT_SKELETON = {
    "allowedTools": [], "mcpContextUris": [], "mcpServers": {},
    "enabledMcpjsonServers": [], "disabledMcpjsonServers": [],
    "hasTrustDialogAccepted": False, "hasClaudeMdExternalIncludesApproved": False,
    "hasClaudeMdExternalIncludesWarningShown": False,
}
EXIT_PARTIAL = 3


class RegistryError(Exception):
    """A refusal that reaches the operator as `mcp-registry: <message>`."""


def _warn(message):
    print(f"mcp-registry: {message}", file=sys.stderr)


# --------------------------------------------------------------------------- io

def _now_iso():
    return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def _read_json(path, default):
    try:
        with open(path, encoding="utf-8") as handle:
            data = json.load(handle)
    except FileNotFoundError:
        return copy.deepcopy(default)
    except (OSError, ValueError) as exc:
        raise RegistryError(f"{path} is not readable JSON: {exc}") from exc
    if not isinstance(data, dict):
        raise RegistryError(f"{path} does not hold a JSON object")
    return data


def _atomic_write(path, text, default_mode=0o600):
    directory = os.path.dirname(path) or "."
    os.makedirs(directory, exist_ok=True)
    try:
        mode = os.stat(path).st_mode & 0o777
    except FileNotFoundError:
        mode = default_mode
    handle, scratch = tempfile.mkstemp(prefix=".mcp-registry-", dir=directory)
    try:
        with os.fdopen(handle, "w", encoding="utf-8") as stream:
            os.fchmod(stream.fileno(), mode)
            stream.write(text)
        os.replace(scratch, path)
    except BaseException:
        try:
            os.unlink(scratch)
        except OSError:
            pass
        raise


def _log(root, message):
    try:
        with open(os.path.join(root, "ops.log"), "a", encoding="utf-8") as handle:
            handle.write(f"{_now_iso()} mcp {message}\n")
    except OSError:
        pass


class _Lock:
    """mkdir lock, the same shape as lib/common.sh mutate_lock (30 s stale)."""

    def __init__(self, root):
        self.path = os.path.join(root, LOCK_DIR)
        self.held = False

    def __enter__(self):
        os.makedirs(os.path.dirname(self.path), exist_ok=True)
        deadline = time.time() + 60
        while True:
            try:
                os.mkdir(self.path)
                self.held = True
                return self
            except FileExistsError:
                try:
                    if time.time() - os.stat(self.path).st_mtime > 30:
                        os.rmdir(self.path)
                        continue
                except OSError:
                    pass
                if time.time() > deadline:
                    raise RegistryError("could not take the registry lock; try again")
                time.sleep(0.1)

    def __exit__(self, *_exc):
        if self.held:
            try:
                os.rmdir(self.path)
            except OSError:
                pass
            self.held = False


# ------------------------------------------------------------ documents

def _normalize_section(section):
    """Every server section — the registry, an overlay owner — has the same shape."""
    if not isinstance(section, dict):
        section = {}
    if not isinstance(section.get("mcpServers"), dict):
        section["mcpServers"] = {}
    for key in ("retired", "retiredUser"):
        if not isinstance(section.get(key), list):
            section[key] = []
        section[key] = [str(name) for name in section[key]]
    if not isinstance(section.get("projects"), dict):
        section["projects"] = {}
    for path, entry in list(section["projects"].items()):
        if not isinstance(entry, dict):
            section["projects"][path] = entry = {}
        if not isinstance(entry.get("mcpServers"), dict):
            entry["mcpServers"] = {}
        if not isinstance(entry.get("retired"), list):
            entry["retired"] = []
        if "enabledMcpjsonServers" in entry and not isinstance(entry["enabledMcpjsonServers"], list):
            entry.pop("enabledMcpjsonServers")
    return section


def _empty_registry():
    return {"version": 1, "mcpServers": {}, "retired": [], "retiredUser": [], "projects": {}}


def load_registry(root):
    doc = _normalize_section(_read_json(os.path.join(root, REGISTRY_FILE), _empty_registry()))
    doc.setdefault("version", 1)
    return doc


def save_registry(root, doc):
    doc["version"] = 1
    doc["updated_at"] = _now_iso()
    _atomic_write(os.path.join(root, REGISTRY_FILE), json.dumps(doc, indent=2) + "\n", 0o644)


def load_overlay(root):
    doc = _read_json(os.path.join(root, OVERLAY_FILE), {"version": 1, "owners": {}})
    if not isinstance(doc.get("owners"), dict):
        doc["owners"] = {}
    for owner in list(doc["owners"]):
        doc["owners"][owner] = _normalize_section(doc["owners"][owner])
    return doc


def save_overlay(root, doc):
    doc["version"] = 1
    doc["updated_at"] = _now_iso()
    _atomic_write(os.path.join(root, OVERLAY_FILE), json.dumps(doc, indent=2) + "\n", 0o644)


def is_replica(root):
    """A pool the source machine pushes to: its registry is the source's copy."""
    try:
        with open(os.path.join(root, "sync-role"), encoding="utf-8") as handle:
            return handle.read().strip().lower() == "replica"
    except OSError:
        return False


class Effective:
    """What every account must hold and what it must not, merged from all sections."""

    def __init__(self):
        self.servers = {}
        self.retired = set()          # everywhere
        self.retired_user = set()     # user scope only
        self.projects = {}

    def _merge_projects(self, projects):
        for path, entry in projects.items():
            target = self.projects.setdefault(path, {"mcpServers": {}, "retired": [],
                                                     "enabledMcpjsonServers": []})
            for name, block in (entry.get("mcpServers") or {}).items():
                if isinstance(block, dict):
                    target["mcpServers"][str(name)] = block
                    target["retired"] = [n for n in target["retired"] if n != name]
            for name in entry.get("retired") or []:
                target["mcpServers"].pop(name, None)
                if name not in target["retired"]:
                    target["retired"].append(name)
            for name in entry.get("enabledMcpjsonServers") or []:
                if name not in target["enabledMcpjsonServers"]:
                    target["enabledMcpjsonServers"].append(name)

    def _apply_section(self, section):
        gone = set(section["retired"])
        gone_user = set(section["retiredUser"])
        for name, block in section["mcpServers"].items():
            if isinstance(block, dict) and name not in gone and name not in gone_user:
                self.servers[str(name)] = block
                self.retired.discard(name)
                self.retired_user.discard(name)
        for name in gone:
            self.servers.pop(name, None)
            self.retired.add(name)
            self.retired_user.discard(name)
        for name in gone_user:
            self.servers.pop(name, None)
            if name not in self.retired:
                self.retired_user.add(name)
        self._merge_projects(section["projects"])


def effective(registry, overlay):
    """Overlay owners first (alphabetically, a later owner wins a clash, a tombstone
    beats a server inside one owner); the synced registry has the final word both
    ways: its servers survive any overlay tombstone and its tombstones remove any
    overlay server."""
    result = Effective()
    for owner in sorted(overlay.get("owners", {})):
        result._apply_section(overlay["owners"][owner])
    result._apply_section(registry)
    return result


# ------------------------------------------------------------- validation

def _validate_name(name, strict=True):
    name = str(name)
    if strict:
        if not STRICT_NAME.match(name):
            raise RegistryError(f"not a usable MCP server name: {name!r} "
                                "(letters, digits, '_' and '-', up to 128 characters)")
        return name
    if not name or name in (".", "..") or len(name) > 128 or re.search(r"[\s/\\]", name):
        raise RegistryError(f"not a usable MCP server name: {name!r}")
    return name


def _scalar(value):
    return isinstance(value, (str, int, float)) and not isinstance(value, bool)


def _number_ok(value, lo=0, hi=MAX_TIMEOUT):
    return (isinstance(value, (int, float)) and not isinstance(value, bool)
            and math.isfinite(value) and lo <= value <= hi)


def validate_block(name, block):
    """The block shape both clients can consume; raises RegistryError otherwise."""
    if not isinstance(block, dict):
        raise RegistryError(f"{name}: the server block must be a JSON object")
    kind = block.get("type")
    if kind is not None and kind not in TRANSPORTS:
        raise RegistryError(f"{name}: unknown transport {kind!r} (stdio, http, sse, ws)")
    command, url = block.get("command"), block.get("url")
    if not ((isinstance(command, str) and command) or (isinstance(url, str) and url)):
        raise RegistryError(f"{name}: needs a non-empty \"command\" or \"url\"")
    if command is not None and not isinstance(command, str):
        raise RegistryError(f"{name}: \"command\" must be a string")
    if url is not None and not isinstance(url, str):
        raise RegistryError(f"{name}: \"url\" must be a string")
    args = block.get("args")
    if args is not None and (not isinstance(args, list) or not all(_scalar(a) for a in args)):
        raise RegistryError(f"{name}: \"args\" must be a list of strings")
    for key in ("env", "headers", "http_headers", "env_http_headers"):
        value = block.get(key)
        if value is not None and (not isinstance(value, dict)
                                  or not all(_scalar(v) for v in value.values())):
            raise RegistryError(f"{name}: \"{key}\" must be an object of string values")
    env_vars = block.get("env_vars")
    if env_vars is not None and (not isinstance(env_vars, list)
                                 or not all(isinstance(v, str) for v in env_vars)):
        raise RegistryError(f"{name}: \"env_vars\" must be a list of variable names")
    for key in ("startup_timeout_sec", "tool_timeout_sec"):
        if key in block and not _number_ok(block[key]):
            raise RegistryError(f"{name}: \"{key}\" must be a number of seconds "
                                f"between 0 and {MAX_TIMEOUT}")
    if "enabled" in block and not isinstance(block["enabled"], bool):
        raise RegistryError(f"{name}: \"enabled\" must be true or false")
    for key in ("cwd", "bearer_token_env_var"):
        if key in block and (not isinstance(block[key], str) or not block[key]):
            raise RegistryError(f"{name}: \"{key}\" must be a non-empty string")
    if "codex" in block and not isinstance(block["codex"], dict):
        raise RegistryError(f"{name}: \"codex\" must be an object")
    return block


# ------------------------------------------------------------------ accounts

def manifest_account_dirs(root):
    manifest = os.path.join(root, "accounts.json")
    try:
        with open(manifest, encoding="utf-8") as handle:
            doc = json.load(handle)
    except FileNotFoundError:
        return []
    except (OSError, ValueError) as exc:
        raise RegistryError(f"{manifest} is not readable JSON: {exc}") from exc
    dirs = []
    for account in doc.get("accounts") or []:
        account_id = account.get("id", "") if isinstance(account, dict) else ""
        if isinstance(account_id, str) and ACCT_ID.match(account_id):
            path = os.path.join(root, account_id)
            if os.path.isdir(path):
                dirs.append(path)
    return dirs


def _sig_part(path):
    try:
        st = os.stat(path)
    except OSError:
        return "-"
    return f"{int(st.st_mtime)}:{st.st_size}"


def stamp_signature(root, provider, account_dir):
    return ",".join(_sig_part(path) for path in (
        os.path.join(root, REGISTRY_FILE), os.path.join(root, OVERLAY_FILE),
        os.path.join(account_dir, CONFIG_FILE[provider])))


def write_stamp(root, provider, account_dir):
    try:
        _atomic_write(os.path.join(account_dir, STAMP_FILE),
                      stamp_signature(root, provider, account_dir) + "\n")
    except OSError:
        pass


# --------------------------------------------------------------- claude side

def _kind_of(block):
    kind = block.get("type")
    if kind in TRANSPORTS:
        return kind
    return "stdio" if block.get("command") else ("http" if block.get("url") else "stdio")


def claude_block(block):
    """Claude Code's own shape, codex-only keys dropped."""
    validate_block(block.get("__name__", "server"), block)
    out = {key: copy.deepcopy(value) for key, value in block.items()
           if key not in CODEX_ONLY_KEYS and key != "__name__"}
    kind = _kind_of(out)
    ordered = {"type": kind}
    if kind == "stdio":
        ordered["command"] = str(out.get("command", ""))
        ordered["args"] = [str(arg) for arg in (out.get("args") or [])]
        env = out.get("env") or {}
        ordered["env"] = {str(k): str(v) for k, v in env.items()}
    else:
        ordered["url"] = str(out.get("url", ""))
        headers = out.get("headers")
        if isinstance(headers, dict) and headers:
            ordered["headers"] = {str(k): str(v) for k, v in headers.items()}
    for key, value in out.items():
        if key not in ordered:
            ordered[key] = value
    return ordered


def _claude_load(account_dir):
    path = os.path.join(account_dir, CONFIG_FILE["claude"])
    try:
        with open(path, encoding="utf-8") as handle:
            doc = json.load(handle)
    except FileNotFoundError:
        return {}
    except (OSError, ValueError) as exc:
        raise RegistryError(f"{path} is not readable JSON: {exc}") from exc
    if not isinstance(doc, dict):
        raise RegistryError(f"{path} does not hold a JSON object")
    return doc


def _claude_save(account_dir, doc):
    _atomic_write(os.path.join(account_dir, CONFIG_FILE["claude"]),
                  json.dumps(doc, indent=2) + "\n")


def _project_entry(doc, path):
    projects = doc.setdefault("projects", {})
    if not isinstance(projects, dict):
        projects = doc["projects"] = {}
    entry = projects.get(path)
    if not isinstance(entry, dict):
        entry = projects[path] = copy.deepcopy(PROJECT_SKELETON)
    if not isinstance(entry.get("mcpServers"), dict):
        entry["mcpServers"] = {}
    return entry


def reconcile_claude(doc, eff):
    """Return (reconciled document, notes). Compare to the input to know if it changed."""
    doc = copy.deepcopy(doc)
    notes = []
    user = doc.get("mcpServers")
    if not isinstance(user, dict):
        user = doc["mcpServers"] = {}
    for name, block in eff.servers.items():
        try:
            user[name] = claude_block(dict(block, __name__=name))
        except RegistryError as exc:
            notes.append(f"{exc} — skipped")
    for name in eff.retired | eff.retired_user:
        user.pop(name, None)
    existing_projects = doc.get("projects")
    if isinstance(existing_projects, dict) and eff.retired:
        for entry in existing_projects.values():
            if isinstance(entry, dict) and isinstance(entry.get("mcpServers"), dict):
                for name in eff.retired:
                    entry["mcpServers"].pop(name, None)
    for path, spec in eff.projects.items():
        wanted = spec.get("mcpServers") or {}
        gone = set(spec.get("retired") or []) | eff.retired
        enabled = spec.get("enabledMcpjsonServers") or []
        if not wanted and not (spec.get("retired") or []) and not enabled:
            continue
        entry = _project_entry(doc, path)
        for name, block in wanted.items():
            if name in eff.retired:
                continue
            try:
                entry["mcpServers"][name] = claude_block(dict(block, __name__=name))
            except RegistryError as exc:
                notes.append(f"{exc} — skipped")
        for name in gone:
            entry["mcpServers"].pop(name, None)
        if enabled:
            current = entry.get("enabledMcpjsonServers")
            current = list(current) if isinstance(current, list) else []
            for name in enabled:
                if name not in current:
                    current.append(name)
            entry["enabledMcpjsonServers"] = current
            disabled = entry.get("disabledMcpjsonServers")
            if isinstance(disabled, list):
                entry["disabledMcpjsonServers"] = [n for n in disabled if n not in enabled]
    return doc, notes


def apply_claude(account_dir, eff):
    before = _claude_load(account_dir)
    after, notes = reconcile_claude(before, eff)
    for note in notes:
        _warn(f"{os.path.basename(account_dir.rstrip('/'))}: {note}")
    if after == before and os.path.exists(os.path.join(account_dir, CONFIG_FILE["claude"])):
        return "unchanged"
    _claude_save(account_dir, after)
    return "updated"


def snapshot_claude(account_dir):
    doc = _claude_load(account_dir)
    user = doc.get("mcpServers") if isinstance(doc.get("mcpServers"), dict) else {}
    projects = {}
    for path, entry in (doc.get("projects") or {}).items():
        if not isinstance(entry, dict):
            continue
        mcp = entry.get("mcpServers") if isinstance(entry.get("mcpServers"), dict) else {}
        enabled = entry.get("enabledMcpjsonServers")
        disabled = entry.get("disabledMcpjsonServers")
        projects[path] = {
            "mcpServers": mcp,
            "enabledMcpjsonServers": list(enabled) if isinstance(enabled, list) else [],
            "disabledMcpjsonServers": list(disabled) if isinstance(disabled, list) else [],
        }
    return {"provider": "claude", "user": user, "projects": projects}


# ---------------------------------------------------------------- codex side

def _tomllib():
    try:
        import tomllib  # noqa: WPS433 — 3.11+ only, hence the lazy import
    except ImportError:
        raise RegistryError("the codex registry needs Python 3.11+ (tomllib); "
                            "install a newer python3 (brew install python)") from None
    return tomllib


def codex_block(name, block):
    """Translate a Claude-shaped block into the keys codex's config.toml takes.

    Returns (table, notes). A None table means "not expressible" (an SSE or
    WebSocket transport): the caller skips the server rather than writing
    something codex would refuse to start. `notes` lists what had to be left out.
    """
    validate_block(name, block)
    notes = []
    kind = _kind_of(block)
    if kind not in ("stdio", "http"):
        return None, [f"{name} uses a transport codex cannot express ({kind}) — skipped"]
    out = {}
    if kind == "stdio":
        out["command"] = str(block.get("command", ""))
        out["args"] = [str(arg) for arg in (block.get("args") or [])]
        env, env_vars = {}, []
        for key, value in (block.get("env") or {}).items():
            key, value = str(key), str(value)
            reference = ENV_REF.match(value)
            if not reference:
                env[key] = value
            elif reference.group(1) == key and not reference.group(2):
                # codex forwards a variable under its own name and expands nothing
                # else, so only KEY=${KEY} survives the translation.
                env_vars.append(key)
            else:
                notes.append(f"{name}: env {key}={value} cannot be expressed for codex "
                             "(it forwards a variable only under its own name, with no "
                             "default) — that variable is left out of the codex server")
        for var in block.get("env_vars") or []:
            if var not in env_vars:
                env_vars.append(str(var))
        if env:
            out["env"] = env
        if env_vars:
            out["env_vars"] = env_vars
        out["startup_timeout_sec"] = block.get("startup_timeout_sec", DEFAULT_STARTUP_TIMEOUT)
    else:
        out["url"] = str(block.get("url", ""))
        headers, env_headers, bearer = {}, {}, None
        for key, value in (block.get("headers") or {}).items():
            key, value = str(key), str(value)
            bearer_ref = BEARER_REF.match(value)
            if bearer_ref and key.lower() == "authorization":
                bearer = bearer_ref.group(1)
                continue
            reference = ENV_REF.match(value)
            if reference and not reference.group(2):
                env_headers[key] = reference.group(1)
            elif reference:
                notes.append(f"{name}: header {key}={value} carries a default codex cannot "
                             "express — header left out of the codex server")
            else:
                headers[key] = value
        for key, source in (("http_headers", headers), ("env_http_headers", env_headers)):
            explicit = block.get(key)
            if isinstance(explicit, dict):
                source.update({str(k): str(v) for k, v in explicit.items()})
            if source:
                out[key] = source
        bearer = block.get("bearer_token_env_var") or bearer
        if bearer:
            out["bearer_token_env_var"] = str(bearer)
        if "startup_timeout_sec" in block:
            out["startup_timeout_sec"] = block["startup_timeout_sec"]
    for key in ("tool_timeout_sec", "cwd", "enabled"):
        if key in block:
            out[key] = block[key]
    for key, value in (block.get("codex") or {}).items():
        if key not in out:
            out[str(key)] = copy.deepcopy(value)
    return out, notes


def codex_to_claude(name, table):
    """The registry (Claude-shaped) block for a table read from codex's config."""
    block = {}
    if table.get("command"):
        block["type"] = "stdio"
        block["command"] = str(table["command"])
        args = table.get("args") or []
        block["args"] = [str(arg) for arg in (args if isinstance(args, list) else [args])]
        env = {str(k): str(v) for k, v in (table.get("env") or {}).items()} \
            if isinstance(table.get("env"), dict) else {}
        env_vars = table.get("env_vars") or []
        for var in (env_vars if isinstance(env_vars, list) else [env_vars]):
            var = var.get("name") if isinstance(var, dict) else var
            if isinstance(var, str) and var:
                env[var] = "${" + var + "}"
        block["env"] = env
    elif table.get("url"):
        block["type"] = "http"
        block["url"] = str(table["url"])
        headers = {}
        if isinstance(table.get("http_headers"), dict):
            headers.update({str(k): str(v) for k, v in table["http_headers"].items()})
        if isinstance(table.get("env_http_headers"), dict):
            headers.update({str(k): "${" + str(v) + "}" for k, v in table["env_http_headers"].items()})
        if table.get("bearer_token_env_var"):
            headers["Authorization"] = "Bearer ${" + str(table["bearer_token_env_var"]) + "}"
        if headers:
            block["headers"] = headers
    else:
        raise RegistryError(f"codex server {name!r} has neither a command nor a url")
    for key in ("startup_timeout_sec", "tool_timeout_sec", "cwd", "enabled"):
        if key in table:
            block[key] = table[key]
    extra = {key: copy.deepcopy(value) for key, value in table.items()
             if key not in CODEX_MODELLED_KEYS}
    if extra:
        block["codex"] = extra
    validate_block(name, block)
    return block


def _toml_key(key):
    return key if BARE_KEY.match(key) else json.dumps(key, ensure_ascii=False)


def _toml_value(value):
    if isinstance(value, bool):
        return "true" if value else "false"
    if isinstance(value, (int, float)):
        if not math.isfinite(value):
            raise RegistryError("cannot write a non-finite number into config.toml")
        return repr(value)
    if isinstance(value, str):
        return json.dumps(value, ensure_ascii=False)
    if isinstance(value, list):
        return "[" + ", ".join(_toml_value(item) for item in value) + "]"
    if isinstance(value, dict):
        return "{ " + ", ".join(f"{_toml_key(str(k))} = {_toml_value(v)}"
                                for k, v in value.items()) + " }"
    raise RegistryError(f"cannot write {type(value).__name__} into config.toml")


def _toml_table(name, table):
    lines = [f"[mcp_servers.{_toml_key(name)}]"]
    for key, value in table.items():
        lines.append(f"{_toml_key(str(key))} = {_toml_value(value)}")
    return "\n".join(lines) + "\n"


def _name_pattern(name):
    escaped = re.escape(name)
    return (r"(?:" + escaped + r"|\"" + escaped + r"\"|'" + escaped + r"')")


def _strip_server_text(text, name):
    """Drop every line that defines `mcp_servers.<name>` — its tables, its
    sub-tables, a dotted key under a bare [mcp_servers] table, or a dotted root
    key. Everything else is preserved byte for byte, including the comments and
    blank lines that sit directly above the next table (they belong to it)."""
    own_header = re.compile(r"^\s*\[\s*mcp_servers\s*\.\s*" + _name_pattern(name)
                            + r"\s*(?:\.[^\]]*)?\]\s*(?:#.*)?$")
    root_header = re.compile(r"^\s*\[\s*mcp_servers\s*\]\s*(?:#.*)?$")
    any_header = re.compile(r"^\s*\[")
    dotted_in_root = re.compile(r"^\s*" + _name_pattern(name) + r"\s*(?:\.|=)")
    dotted_top = re.compile(r"^\s*mcp_servers\s*\.\s*" + _name_pattern(name) + r"\s*(?:\.|=)")
    state = "top"
    out, pending = [], []
    for line in text.splitlines(keepends=True):
        if any_header.match(line):
            if own_header.match(line.rstrip("\r\n")):
                state = "mine"
                pending = []
                continue
            out.extend(pending)
            pending = []
            state = "root" if root_header.match(line.rstrip("\r\n")) else "other"
            out.append(line)
            continue
        if state == "mine":
            if not line.strip() or line.lstrip().startswith("#"):
                pending.append(line)
            else:
                pending = []
            continue
        if state == "root" and dotted_in_root.match(line):
            continue
        if state == "top" and dotted_top.match(line):
            continue
        out.append(line)
    out.extend(line for line in pending if line.strip())
    return "".join(out)


def _without(doc, names):
    doc = copy.deepcopy(doc)
    servers = doc.get("mcp_servers")
    if isinstance(servers, dict):
        for name in names:
            servers.pop(name, None)
        if not servers:
            doc.pop("mcp_servers")
    return doc


def reconcile_codex_text(text, eff):
    """Return (new_text, notes)."""
    tomllib = _tomllib()
    try:
        current = tomllib.loads(text)
    except tomllib.TOMLDecodeError as exc:
        raise RegistryError(f"config.toml does not parse: {exc}") from exc
    existing = current.get("mcp_servers") if isinstance(current.get("mcp_servers"), dict) else {}
    wanted, notes = {}, []
    for name, block in eff.servers.items():
        try:
            table, block_notes = codex_block(name, block)
        except RegistryError as exc:
            notes.append(f"{exc} — skipped")
            continue
        notes.extend(block_notes)
        if table is not None:
            wanted[name] = table
    retired = eff.retired | eff.retired_user
    touched = [name for name, table in wanted.items() if existing.get(name) != table]
    touched += [name for name in retired if name in existing]
    if not touched:
        return text, notes
    result = text
    for name in touched:
        result = _strip_server_text(result, name)
    additions = [_toml_table(name, wanted[name]) for name in touched if name in wanted]
    if additions:
        if result and not result.endswith("\n"):
            result += "\n"
        if result and not result.endswith("\n\n"):
            result += "\n"
        result += "\n".join(additions)
    try:
        parsed = tomllib.loads(result)
    except tomllib.TOMLDecodeError as exc:
        raise RegistryError(f"refusing to write config.toml: the edit would not parse ({exc})") from exc
    if _without(parsed, touched) != _without(current, touched):
        raise RegistryError("refusing to write config.toml: an unrelated setting would change "
                            "(unsupported layout for mcp_servers)")
    written = parsed.get("mcp_servers") if isinstance(parsed.get("mcp_servers"), dict) else {}
    for name in touched:
        if name in wanted and written.get(name) != wanted[name]:
            raise RegistryError(f"refusing to write config.toml: {name} did not round-trip")
        if name not in wanted and name in written:
            raise RegistryError(f"refusing to write config.toml: {name} could not be removed")
    return result, notes


def apply_codex(account_dir, eff):
    path = os.path.join(account_dir, CONFIG_FILE["codex"])
    try:
        with open(path, encoding="utf-8") as handle:
            text = handle.read()
    except FileNotFoundError:
        text = ""
    except (OSError, UnicodeDecodeError) as exc:
        raise RegistryError(f"{path}: {exc}") from exc
    result, notes = reconcile_codex_text(text, eff)
    label = os.path.basename(account_dir.rstrip("/"))
    for note in notes:
        _warn(f"{label}: {note}")
    if result == text and os.path.exists(path):
        return "unchanged"
    _atomic_write(path, result)
    return "updated"


def snapshot_codex(account_dir):
    tomllib = _tomllib()
    path = os.path.join(account_dir, CONFIG_FILE["codex"])
    try:
        with open(path, encoding="utf-8") as handle:
            doc = tomllib.loads(handle.read())
    except FileNotFoundError:
        doc = {}
    except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as exc:
        raise RegistryError(f"{path}: {exc}") from exc
    servers = doc.get("mcp_servers") if isinstance(doc.get("mcp_servers"), dict) else {}
    return {"provider": "codex",
            "user": {name: table for name, table in servers.items() if isinstance(table, dict)}}


# ---------------------------------------------------------------- reconcile

def apply_account(root, provider, account_dir):
    eff = effective(load_registry(root), load_overlay(root))
    if provider == "claude":
        state = apply_claude(account_dir, eff)
    else:
        state = apply_codex(account_dir, eff)
    write_stamp(root, provider, account_dir)
    return state


def apply_many(root, provider, account_dirs, quiet=False, fail_open=False):
    """Reconcile each dir; returns the number of accounts that could not be."""
    failures = 0
    for account_dir in account_dirs:
        label = os.path.basename(account_dir.rstrip("/"))
        if not os.path.isdir(account_dir):
            if not quiet:
                print(f"{label}: skipped (no such directory)")
            continue
        try:
            state = apply_account(root, provider, account_dir)
        except Exception as exc:  # noqa: BLE001 — one account must never stop the rest
            failures += 1
            _warn(f"{label}: {type(exc).__name__ if not isinstance(exc, RegistryError) else ''}"
                  f"{': ' if not isinstance(exc, RegistryError) else ''}{exc}")
            continue
        if not quiet:
            print(f"{label}: {state}")
    return failures


def _registry_present(root):
    return any(os.path.exists(os.path.join(root, name)) for name in (REGISTRY_FILE, OVERLAY_FILE))


# ------------------------------------------------------------------- learn

def snapshot(provider, account_dir):
    return snapshot_claude(account_dir) if provider == "claude" else snapshot_codex(account_dir)


def _learn_into(section, before, after):
    """Mirror the difference between two snapshots into a section (registry or
    overlay owner). Returns human-readable change descriptions (empty = nothing)."""
    changes = []
    provider = after.get("provider")
    before_user, after_user = before.get("user") or {}, after.get("user") or {}
    for name, block in after_user.items():
        if before_user.get(name) == block:
            continue
        try:
            learned = block if provider == "claude" else codex_to_claude(name, block)
            validate_block(name, learned)
        except RegistryError as exc:
            _warn(f"not mirrored: {exc}")
            continue
        section["mcpServers"][name] = learned
        section["retired"] = [n for n in section["retired"] if n != name]
        section["retiredUser"] = [n for n in section["retiredUser"] if n != name]
        changes.append(f'"{name}"')
    for name in before_user:
        if name not in after_user:
            section["mcpServers"].pop(name, None)
            # The client removed the USER-scope entry and nothing else: mirror exactly
            # that. Project entries of the same name stay, in every account.
            if name not in section["retiredUser"] and name not in section["retired"]:
                section["retiredUser"].append(name)
            changes.append(f'"{name}" (removed)')
    if provider != "claude":
        return changes
    before_projects, after_projects = before.get("projects") or {}, after.get("projects") or {}
    for path in sorted(set(before_projects) | set(after_projects)):
        old, new = before_projects.get(path) or {}, after_projects.get(path) or {}
        old_mcp, new_mcp = old.get("mcpServers") or {}, new.get("mcpServers") or {}
        for name, block in new_mcp.items():
            if old_mcp.get(name) != block:
                try:
                    validate_block(name, block)
                except RegistryError as exc:
                    _warn(f"not mirrored: {exc}")
                    continue
                entry = section["projects"].setdefault(path, {"mcpServers": {}, "retired": []})
                entry.setdefault("mcpServers", {})[name] = block
                entry["retired"] = [n for n in entry.get("retired", []) if n != name]
                changes.append(f'"{name}" (project {path})')
        for name in old_mcp:
            if name not in new_mcp:
                entry = section["projects"].setdefault(path, {"mcpServers": {}, "retired": []})
                entry.setdefault("mcpServers", {}).pop(name, None)
                entry.setdefault("retired", [])
                if name not in entry["retired"]:
                    entry["retired"].append(name)
                changes.append(f'"{name}" (removed from project {path})')
        old_enabled = set(old.get("enabledMcpjsonServers") or [])
        new_enabled = set(new.get("enabledMcpjsonServers") or [])
        if new_enabled - old_enabled:
            entry = section["projects"].setdefault(path, {"mcpServers": {}, "retired": []})
            current = entry.get("enabledMcpjsonServers")
            current = list(current) if isinstance(current, list) else []
            for name in sorted(new_enabled - old_enabled):
                if name not in current:
                    current.append(name)
            entry["enabledMcpjsonServers"] = current
            changes.append(f"approved .mcp.json servers for project {path}")
    return changes


# --------------------------------------------------------------------- CLI

def _parse_env(items):
    env = {}
    for item in items or []:
        if "=" not in item:
            raise RegistryError(f"-e expects KEY=VALUE, got {item!r}")
        key, value = item.split("=", 1)
        env[key] = value
    return env


def _parse_headers(items):
    headers = {}
    for item in items or []:
        if ":" not in item:
            raise RegistryError(f"-H expects 'Header: value', got {item!r}")
        key, value = item.split(":", 1)
        headers[key.strip()] = value.strip()
    return headers


def _split_command(argv):
    if "--" in argv:
        index = argv.index("--")
        return argv[:index], argv[index + 1:]
    return argv, []


def _normalize_project(path):
    if not path:
        return path
    return os.path.realpath(os.path.expanduser(path))


class _Target:
    """Where a mutation lands: the synced registry, or — on a replica, whose registry
    the source machine overwrites on every push — this machine's overlay."""

    def __init__(self, root):
        self.root = root
        self.replica = is_replica(root)
        self.overlay = load_overlay(root) if self.replica else None
        if self.replica:
            self.section = self.overlay["owners"].setdefault("local", _normalize_section({}))
        else:
            self.section = load_registry(root)

    def save(self):
        if self.replica:
            save_overlay(self.root, self.overlay)
        else:
            save_registry(self.root, self.section)

    @property
    def where(self):
        return "this machine's overlay (replica pool)" if self.replica else "the registry"


def _upsert(section, name, block, scope, project):
    if scope == "project":
        if not project:
            raise RegistryError("--scope project needs --project PATH")
        entry = section["projects"].setdefault(project, {"mcpServers": {}, "retired": []})
        entry.setdefault("mcpServers", {})[name] = block
        entry["retired"] = [n for n in entry.get("retired", []) if n != name]
    else:
        section["mcpServers"][name] = block
        section["retired"] = [n for n in section["retired"] if n != name]
        section["retiredUser"] = [n for n in section["retiredUser"] if n != name]


def _retire(section, name, scope, project, user_only=False):
    if scope == "project":
        if not project:
            raise RegistryError("--scope project needs --project PATH")
        entry = section["projects"].setdefault(project, {"mcpServers": {}, "retired": []})
        entry.setdefault("mcpServers", {}).pop(name, None)
        entry.setdefault("retired", [])
        if name not in entry["retired"]:
            entry["retired"].append(name)
        return
    section["mcpServers"].pop(name, None)
    key, other = ("retiredUser", "retired") if user_only else ("retired", "retiredUser")
    if name not in section[key]:
        section[key].append(name)
    section[other] = [n for n in section[other] if n != name]


def _describe(block):
    if not isinstance(block, dict):
        return "invalid block"
    kind = _kind_of(block)
    if kind == "stdio":
        args = block.get("args") or []
        args = args if isinstance(args, list) else [args]
        return f"{kind:5} " + " ".join(shlex.quote(str(x)) for x in [block.get("command", "")] + args)
    return f"{kind:5} {block.get('url', '')}"


def cmd_list(args):
    registry = load_registry(args.root)
    overlay = load_overlay(args.root)
    eff = effective(registry, overlay)
    if args.json:
        print(json.dumps({"mcpServers": eff.servers, "retired": sorted(eff.retired),
                          "retiredUser": sorted(eff.retired_user), "projects": eff.projects,
                          "overlayOwners": sorted(overlay.get("owners", {})),
                          "replica": is_replica(args.root)}, indent=2))
        return 0
    if not eff.servers and not eff.retired and not eff.retired_user and not eff.projects:
        print(f"no MCP servers registered for the {args.provider} pool")
        return 0
    if eff.servers:
        print("servers (every account):")
        for name in sorted(eff.servers):
            origin = "registry" if name in registry["mcpServers"] else "overlay"
            print(f"  {name:24} {_describe(eff.servers[name])}  [{origin}]")
    if eff.retired:
        print("retired (removed from every account, every scope): " + ", ".join(sorted(eff.retired)))
    if eff.retired_user:
        print("retired at user scope only: " + ", ".join(sorted(eff.retired_user)))
    for path, entry in sorted(eff.projects.items()):
        print(f"project {path}:")
        for name in sorted(entry.get("mcpServers") or {}):
            print(f"  {name:24} {_describe(entry['mcpServers'][name])}")
        if entry.get("retired"):
            print("  retired: " + ", ".join(sorted(entry["retired"])))
        if entry.get("enabledMcpjsonServers"):
            print("  approved .mcp.json servers: " + ", ".join(entry["enabledMcpjsonServers"]))
    if is_replica(args.root):
        print("(replica pool: changes made here stay in this machine's overlay; make fleet-wide "
              "changes on the source machine)")
    return 0


def _apply_all(args, quiet):
    dirs = manifest_account_dirs(args.root)
    if not dirs and not quiet:
        print("no accounts in the manifest yet — the registry applies as accounts are added")
    failures = apply_many(args.root, args.provider, dirs, quiet=quiet)
    if failures:
        _warn(f"{failures} account(s) not reconciled — fix them and run: "
              f"{args.provider}-accounts mcp apply")
        return EXIT_PARTIAL
    return 0


def cmd_add(args, command, leftovers):
    _validate_name(args.name)
    url = args.url
    if leftovers:
        if url or len(leftovers) > 1 or leftovers[0].startswith("-"):
            raise RegistryError(f"unexpected argument(s): {' '.join(leftovers)}")
        url = leftovers[0]
    transport = args.transport
    if transport is None:
        transport = "http" if (url and not command) else "stdio"
    if transport == "stdio":
        if not command:
            raise RegistryError("a stdio server needs `-- COMMAND [ARGS...]`")
        block = {"type": "stdio", "command": command[0], "args": command[1:],
                 "env": _parse_env(args.env)}
    else:
        if not url:
            raise RegistryError(f"a {transport} server needs its URL (--url URL)")
        block = {"type": transport, "url": url}
        headers = _parse_headers(args.header)
        if headers:
            block["headers"] = headers
    validate_block(args.name, block)
    return _store(args, block)


def cmd_add_json(args, leftovers):
    if leftovers:
        raise RegistryError(f"unexpected argument(s): {' '.join(leftovers)}")
    _validate_name(args.name)
    try:
        block = json.loads(args.block)
    except ValueError as exc:
        raise RegistryError(f"not JSON: {exc}") from exc
    validate_block(args.name, block)
    return _store(args, block)


def _store(args, block):
    project = _normalize_project(args.project)
    with _Lock(args.root):
        target = _Target(args.root)
        _upsert(target.section, args.name, block, args.scope, project)
        target.save()
    where = f"project {project}" if args.scope == "project" else "user scope"
    _log(args.root, f"add {args.name} ({where}) {_describe(block)}")
    print(f"registered {args.name} ({where}) in {target.where} for every {args.provider} account")
    return _apply_all(args, quiet=False)


def cmd_remove(args, leftovers):
    if leftovers:
        raise RegistryError(f"unexpected argument(s): {' '.join(leftovers)}")
    _validate_name(args.name, strict=False)
    project = _normalize_project(args.project)
    with _Lock(args.root):
        target = _Target(args.root)
        _retire(target.section, args.name, args.scope, project, user_only=args.user_only)
        target.save()
    if args.scope == "project":
        where = f"project {project}"
    else:
        where = "user scope only" if args.user_only else "every scope"
    _log(args.root, f"remove {args.name} ({where})")
    print(f"retired {args.name} ({where}) in {target.where}: removed from every "
          f"{args.provider} account")
    return _apply_all(args, quiet=False)


def cmd_apply(args, leftovers):
    if leftovers:
        raise RegistryError(f"unexpected argument(s): {' '.join(leftovers)}")
    if args.all or not args.account_dir:
        rc = _apply_all(args, quiet=args.quiet)
        return 0 if args.fail_open else rc
    failures = apply_many(args.root, args.provider, args.account_dir, quiet=args.quiet)
    return 0 if args.fail_open or not failures else 1


def cmd_snapshot(args):
    print(json.dumps(snapshot(args.provider, args.account_dir)))
    return 0


def cmd_learn(args):
    try:
        with open(args.before, encoding="utf-8") as handle:
            before = json.load(handle)
    except (OSError, ValueError) as exc:
        raise RegistryError(f"snapshot {args.before}: {exc}") from exc
    after = snapshot(args.provider, args.account_dir)
    with _Lock(args.root):
        target = _Target(args.root)
        changes = _learn_into(target.section, before, after)
        if changes:
            target.save()
    if not changes:
        return 0
    _log(args.root, "learn " + ", ".join(changes))
    dirs = manifest_account_dirs(args.root)
    failures = apply_many(args.root, args.provider, dirs, quiet=True)
    print(f"mcp-registry: mirrored {', '.join(changes)} to {len(dirs)} {args.provider} "
          f"account(s) via {target.where}", file=sys.stderr)
    if target.replica:
        print("mcp-registry: this pool is a replica — the change stays on this machine; "
              "make fleet-wide changes on the source machine", file=sys.stderr)
    return EXIT_PARTIAL if failures else 0


def cmd_import_local(args):
    try:
        payload = json.loads(sys.stdin.read() or "{}")
    except ValueError as exc:
        raise RegistryError(f"stdin is not JSON: {exc}") from exc
    if not isinstance(payload, dict):
        raise RegistryError("stdin must hold a JSON object")
    servers = payload.get("mcpServers") or {}
    retired = payload.get("retired") or []
    if not isinstance(servers, dict) or not isinstance(retired, list):
        raise RegistryError("expected {\"mcpServers\": {...}, \"retired\": [...]}")
    owner = _validate_name(args.owner)
    for name in list(servers):
        _validate_name(name)
        validate_block(name, servers[name])
    retired = [_validate_name(str(name), strict=False) for name in retired]
    with _Lock(args.root):
        overlay = load_overlay(args.root)
        previous = overlay["owners"].get(owner) or _normalize_section({})
        # A server this owner published before and no longer names is retired for
        # it: an owner that stops shipping a server means it, and every account
        # would otherwise keep the stale copy for ever.
        dropped = [name for name in previous["mcpServers"] if name not in servers]
        section = _normalize_section({"mcpServers": servers,
                                      "retired": sorted(set(retired) | set(dropped))})
        if previous == section and owner in overlay["owners"]:
            changed = False
        else:
            overlay["owners"][owner] = section
            save_overlay(args.root, overlay)
            changed = True
    if changed:
        _log(args.root, f"import-local owner={owner} servers={','.join(sorted(servers)) or '-'} "
                        f"retired={','.join(sorted(section['retired'])) or '-'}")
    return _apply_all(args, quiet=args.quiet)


def build_parser():
    parser = argparse.ArgumentParser(prog="mcp_registry.py", description=__doc__.split("\n\n")[0])
    parser.add_argument("--root", required=True, help="pool root (holds accounts.json)")
    parser.add_argument("--provider", required=True, choices=PROVIDERS)
    modes = parser.add_subparsers(dest="mode")

    p = modes.add_parser("list")
    p.add_argument("--json", action="store_true")

    for mode in ("add", "add-json"):
        p = modes.add_parser(mode)
        p.add_argument("name")
        if mode == "add-json":
            p.add_argument("block", help="Claude-style JSON block")
        else:
            p.add_argument("--url", help="URL for --transport http/sse (a bare URL argument works too)")
            p.add_argument("--transport", choices=("stdio", "http", "sse"))
            p.add_argument("-e", "--env", action="append", default=[])
            p.add_argument("-H", "--header", action="append", default=[])
        p.add_argument("--scope", choices=("user", "project"), default="user")
        p.add_argument("--project")

    p = modes.add_parser("remove")
    p.add_argument("name")
    p.add_argument("--scope", choices=("user", "project"), default="user")
    p.add_argument("--project")
    p.add_argument("--user-only", action="store_true",
                   help="retire the user-scope entry only; project entries keep the name")

    p = modes.add_parser("apply")
    p.add_argument("--account-dir", action="append", default=[])
    p.add_argument("--all", action="store_true")
    p.add_argument("--fail-open", action="store_true")
    p.add_argument("--quiet", action="store_true")

    p = modes.add_parser("snapshot")
    p.add_argument("--account-dir", required=True)

    p = modes.add_parser("learn")
    p.add_argument("--account-dir", required=True)
    p.add_argument("--before", required=True)
    p.add_argument("--project", help="accepted for the shim's sake; every changed project is mirrored")

    p = modes.add_parser("import-local")
    p.add_argument("--owner", required=True)
    p.add_argument("--quiet", action="store_true")
    return parser


def main(argv=None):
    argv = list(sys.argv[1:] if argv is None else argv)
    head, command = _split_command(argv)
    parser = build_parser()
    # parse_known_args, not parse_args: an optional positional after options (the URL
    # in `add NAME --transport http URL`) is "unrecognized" to argparse before 3.12.
    args, leftovers = parser.parse_known_args(head)
    if not args.mode:
        parser.error("a mode is required (list, add, add-json, remove, apply, snapshot, "
                     "learn, import-local)")
    if not os.path.isdir(args.root):
        raise RegistryError(f"pool root {args.root} does not exist")
    if args.provider == "codex" and sys.version_info < (3, 11):
        from codex_python import require_python311
        require_python311(os.path.abspath(__file__))
    if args.mode == "apply" and not _registry_present(args.root):
        return 0   # nothing registered: every account is already "reconciled"
    if args.mode == "list":
        return cmd_list(args)
    if args.mode == "add":
        return cmd_add(args, command, leftovers)
    if args.mode == "add-json":
        return cmd_add_json(args, leftovers)
    if args.mode == "remove":
        return cmd_remove(args, leftovers)
    if args.mode == "apply":
        return cmd_apply(args, leftovers)
    if leftovers:
        raise RegistryError(f"unexpected argument(s): {' '.join(leftovers)}")
    if args.mode == "snapshot":
        return cmd_snapshot(args)
    if args.mode == "learn":
        return cmd_learn(args)
    if args.mode == "import-local":
        return cmd_import_local(args)
    parser.error(f"unknown mode {args.mode}")
    return 2


if __name__ == "__main__":
    fail_open = "--fail-open" in sys.argv
    try:
        sys.exit(main())
    except RegistryError as error:
        _warn(str(error))
        sys.exit(0 if fail_open else 1)
    except SystemExit as error:
        # argparse usage errors and codex_python's "no python 3.11" both land here.
        if fail_open and error.code not in (0, None):
            if isinstance(error.code, str):
                _warn(error.code)
            sys.exit(0)
        raise
    except Exception as error:  # noqa: BLE001 — a traceback is never the shim's problem
        _warn(f"{type(error).__name__}: {error}")
        sys.exit(0 if fail_open else 1)
