diff --git a/CHANGELOG.md b/CHANGELOG.md index cacf7f3a..2bfa35af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project does not yet follow semantic versioning (pre-1.0). ### Added +- `codex-shim doctor`, a read-only local diagnostics command covering Python, + dependencies, Codex CLI availability, settings, runtime files, daemon health, + passthrough readiness, proxy loopback bypass, and Codex config wiring with + stable OK/WARN/FAIL/INFO output and summary exit-code handling. - Auto Router (`codex_shim/router.py`): an optional `Auto (smart routing)` picker entry (slug `codex-auto`) that routes each task to the cheapest configured model that can handle it. A cheap classifier model scores every candidate diff --git a/README.md b/README.md index 4c410796..b20b6b19 100644 --- a/README.md +++ b/README.md @@ -887,6 +887,7 @@ codex-shim generate regenerate catalog/config without starting daemon codex-shim start regenerate catalog and start local shim daemon codex-shim enable start daemon and write managed ~/.codex/config.toml block codex-shim status health check + model count +codex-shim doctor read-only local diagnostics report codex-shim stop stop daemon codex-shim disable remove managed config block and stop daemon codex-shim restart stop, regenerate, and start daemon @@ -906,8 +907,8 @@ codex-model [list|] shortcut for `codex-shim model …` Global flags: -- `--settings `: used by catalog/model/start/app/codex flows. -- `--port `: used by daemon/provider flows. +- `--settings `: used by catalog/model/start/app/codex/doctor flows. +- `--port `: used by daemon/provider/doctor flows. `patch-app` and `restore-app` always target `/Applications/Codex.app`, do not use `--settings`, and exit with a clear error on Windows/Linux. @@ -974,10 +975,18 @@ the shim, so a visited web page cannot drive them via DNS rebinding. ### Shim will not start ```bash +codex-shim doctor codex-shim status tail -n 80 .codex-shim/shim.log ``` +`codex-shim doctor` prints a read-only diagnostics report grouped by section +(Python, dependencies, Codex CLI, settings, runtime files, daemon health, +passthrough availability, proxy bypass, and Codex config). It never writes +configuration, starts/stops the daemon, calls model providers, or prints API +keys/tokens. It exits 1 only when a hard `FAIL` is detected; warnings are meant +as local setup hints. + Common causes: - Python is older than 3.11. diff --git a/codex_shim/cli.py b/codex_shim/cli.py index 2fca0473..5cb51f5c 100644 --- a/codex_shim/cli.py +++ b/codex_shim/cli.py @@ -1,10 +1,14 @@ from __future__ import annotations import argparse +from collections import Counter +from dataclasses import dataclass +import importlib.util import os from pathlib import Path import ctypes import signal +import shutil import subprocess import sys import time @@ -26,6 +30,7 @@ DEFAULT_SETTINGS, DEFAULT_HOST, DEFAULT_PORT, + DEFAULT_CODEX_AUTH, PROVIDER_NAME, ModelSettings, available_model_slugs, @@ -88,6 +93,7 @@ def main(argv: list[str] | None = None) -> int: sub.add_parser("disable") sub.add_parser("restart") sub.add_parser("status") + sub.add_parser("doctor", help="Print a read-only local diagnostics report.") sub.add_parser("patch-app", help="Patch Codex Desktop picker/sidebar handling for custom shim models.") sub.add_parser("restore-app", help="Restore Codex Desktop app.asar from the pre-patch backup.") @@ -134,6 +140,8 @@ def main(argv: list[str] | None = None) -> int: return start(args.settings, args.port) if args.command == "status": return status(args.port) + if args.command == "doctor": + return doctor(args.settings, args.port) if args.command == "patch-app": return patch_codex_app() if args.command == "restore-app": @@ -185,6 +193,324 @@ def _active_router(models, settings_path: Path): return None +@dataclass(frozen=True) +class DoctorCheck: + section: str + status: str # OK | WARN | FAIL | INFO + message: str + detail: str = "" + + +def doctor(settings_path: Path, port: int) -> int: + """Print a read-only diagnostics report for the local codex-shim setup.""" + expanded = Path(settings_path).expanduser() + checks: list[DoctorCheck] = [] + checks.extend(_doctor_python()) + checks.extend(_doctor_dependencies()) + checks.extend(_doctor_codex_cli()) + checks.extend(_doctor_settings(expanded)) + checks.extend(_doctor_runtime_files()) + checks.extend(_doctor_daemon(port)) + checks.extend(_doctor_chatgpt()) + checks.extend(_doctor_cursor()) + checks.extend(_doctor_proxy_env()) + checks.extend(_doctor_codex_config()) + _print_doctor_report(checks) + return 1 if any(check.status == "FAIL" for check in checks) else 0 + + +def _doctor_python() -> list[DoctorCheck]: + version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + status = "OK" if sys.version_info >= (3, 11) else "FAIL" + detail = "" if status == "OK" else "codex-shim requires Python 3.11+" + return [ + DoctorCheck("Python", status, f"version: {version}", detail), + DoctorCheck("Python", "OK", f"executable: {sys.executable}"), + ] + + +def _doctor_dependencies() -> list[DoctorCheck]: + if importlib.util.find_spec("aiohttp") is None: + return [ + DoctorCheck( + "Dependencies", + "FAIL", + "aiohttp is not importable", + "Try: python3 -m pip install -e .", + ) + ] + return [DoctorCheck("Dependencies", "OK", "aiohttp importable")] + + +def _doctor_codex_cli() -> list[DoctorCheck]: + found = shutil.which("codex") + if not found: + return [ + DoctorCheck( + "Codex CLI", + "WARN", + "codex not found on PATH", + "Install and authenticate Codex before using codex-shim app/codex flows.", + ) + ] + checks = [DoctorCheck("Codex CLI", "OK", f"found: {found}")] + try: + result = subprocess.run([found, "--version"], capture_output=True, text=True, timeout=5) + except (OSError, subprocess.TimeoutExpired) as exc: + checks.append(DoctorCheck("Codex CLI", "WARN", "could not run codex --version", str(exc))) + return checks + output = (result.stdout or result.stderr).strip().splitlines() + version = output[0].strip() if output else "" + if len(version) > 200: + version = version[:197] + "..." + if result.returncode == 0: + checks.append(DoctorCheck("Codex CLI", "OK", f"version: {version}")) + else: + checks.append(DoctorCheck("Codex CLI", "WARN", "codex --version failed", version)) + return checks + + +def _doctor_settings(settings_path: Path) -> list[DoctorCheck]: + section = "Settings" + path = settings_path.expanduser() + if not path.exists(): + detail = "Create ~/.codex-shim/models.json or run codex login for ChatGPT passthrough-only use." + return [DoctorCheck(section, "WARN", f"settings file not found: {path}", detail)] + checks = [DoctorCheck(section, "OK", f"path: {path}")] + try: + models = _load_models(path) + except SystemExit as exc: + message = str(exc) + if "not valid JSON" in message or "invalid JSON" in message: + return [DoctorCheck(section, "FAIL", f"invalid JSON: {path}", message)] + return [DoctorCheck(section, "FAIL", f"could not load settings: {path}", message)] + except Exception as exc: + return [DoctorCheck(section, "FAIL", f"could not load settings: {path}", str(exc))] + + usable = usable_byok_models(models) + missing_count = len(models) - len(usable) + checks.append(DoctorCheck(section, "OK", f"configured models: {len(models)}")) + checks.append(DoctorCheck(section, "OK", f"usable BYOK models: {len(usable)}")) + if missing_count: + checks.append(DoctorCheck(section, "WARN", f"models missing API keys: {missing_count}")) + else: + checks.append(DoctorCheck(section, "OK", "models missing API keys: 0")) + providers = Counter(model.provider for model in models) + provider_text = ", ".join(f"{provider}={count}" for provider, count in sorted(providers.items())) or "none" + checks.append(DoctorCheck(section, "INFO", f"providers: {provider_text}")) + + router_config = router_module.load_router_config(path) + if router_config is None: + checks.append(DoctorCheck(section, "INFO", "auto router configured: false")) + else: + active = _active_router(models, path) + if active is not None: + checks.append(DoctorCheck(section, "OK", f"auto router active: {active.slug}")) + elif router_config.effective_enabled: + checks.append( + DoctorCheck( + section, + "WARN", + f"auto router configured but inactive: {router_config.slug}", + "Ensure at least one router candidate matches a usable model slug.", + ) + ) + else: + checks.append(DoctorCheck(section, "INFO", f"auto router configured but disabled: {router_config.slug}")) + return checks + + +def _doctor_runtime_files() -> list[DoctorCheck]: + checks: list[DoctorCheck] = [] + if CATALOG_PATH.exists(): + checks.append(DoctorCheck("Runtime files", "OK", f"catalog: {CATALOG_PATH}")) + try: + data = json.loads(CATALOG_PATH.read_text()) + models = data.get("models", []) if isinstance(data, dict) else [] + count = len(models) if isinstance(models, list) else 0 + checks.append(DoctorCheck("Runtime files", "OK", f"catalog models: {count}")) + except (OSError, json.JSONDecodeError) as exc: + checks.append( + DoctorCheck("Runtime files", "WARN", f"catalog JSON is not readable: {CATALOG_PATH}", str(exc)) + ) + else: + checks.append(DoctorCheck("Runtime files", "INFO", f"catalog missing: {CATALOG_PATH}")) + if CONFIG_PATH.exists(): + checks.append(DoctorCheck("Runtime files", "OK", f"config: {CONFIG_PATH}")) + else: + checks.append(DoctorCheck("Runtime files", "INFO", f"config missing: {CONFIG_PATH}")) + if PID_PATH.exists(): + checks.append(DoctorCheck("Runtime files", "INFO", f"pid file: {PID_PATH}")) + else: + checks.append(DoctorCheck("Runtime files", "INFO", f"pid file missing: {PID_PATH}")) + if LOG_PATH.exists(): + checks.append(DoctorCheck("Runtime files", "INFO", f"log file: {LOG_PATH}")) + else: + checks.append(DoctorCheck("Runtime files", "INFO", f"log file missing: {LOG_PATH}")) + return checks + + +def _doctor_daemon(port: int) -> list[DoctorCheck]: + checks = [DoctorCheck("Shim daemon", "INFO", f"health URL: http://{DEFAULT_HOST}:{port}/health")] + pid = _read_pid() + if pid is None: + checks.append(DoctorCheck("Shim daemon", "INFO", f"pid file missing or unreadable: {PID_PATH}")) + elif _pid_running(pid): + checks.append(DoctorCheck("Shim daemon", "OK", f"pid {pid} is running")) + else: + checks.append(DoctorCheck("Shim daemon", "WARN", f"pid {pid} is not running")) + + health = _health(port) + if health is None: + checks.append(DoctorCheck("Shim daemon", "WARN", "health endpoint unavailable")) + return checks + model_count = _health_model_count(health.get("models")) + if health.get("ok") is True: + checks.append(DoctorCheck("Shim daemon", "OK", f"health ok: {model_count} models")) + else: + checks.append(DoctorCheck("Shim daemon", "WARN", f"health not ok: {model_count} models")) + for key in ("chatgpt_passthrough", "cursor_passthrough", "auto_router"): + if key in health: + checks.append(DoctorCheck("Shim daemon", "INFO", f"{key}: {_bool_text(health.get(key))}")) + return checks + + +def _health_model_count(value) -> int: + if isinstance(value, int): + return value + if isinstance(value, list): + return len(value) + return 0 + + +def _doctor_chatgpt() -> list[DoctorCheck]: + if _env_flag("CODEX_SHIM_DISABLE_CHATGPT"): + return [DoctorCheck("ChatGPT passthrough", "INFO", "disabled via CODEX_SHIM_DISABLE_CHATGPT")] + auth_path = Path(DEFAULT_CODEX_AUTH).expanduser() + if chatgpt_passthrough_available(): + return [DoctorCheck("ChatGPT passthrough", "OK", f"available via {auth_path}")] + if auth_path.exists(): + detail = "Run `codex login` again if you want ChatGPT/Codex passthrough." + else: + detail = "Run `codex login` if you want ChatGPT/Codex passthrough." + return [DoctorCheck("ChatGPT passthrough", "WARN", "unavailable", detail)] + + +def _doctor_cursor() -> list[DoctorCheck]: + if _env_flag("CODEX_SHIM_DISABLE_CURSOR"): + return [DoctorCheck("Cursor passthrough", "INFO", "disabled via CODEX_SHIM_DISABLE_CURSOR")] + bin_override = os.environ.get("CURSOR_AGENT_BIN", "").strip() + agent_bin = bin_override or shutil.which("cursor-agent") + checks: list[DoctorCheck] = [] + if agent_bin: + checks.append(DoctorCheck("Cursor passthrough", "INFO", f"cursor-agent: {agent_bin}")) + else: + checks.append(DoctorCheck("Cursor passthrough", "WARN", "cursor-agent not found on PATH")) + if cursor_passthrough_available(): + checks.append(DoctorCheck("Cursor passthrough", "OK", "cursor-agent logged in")) + for slug in sorted(cursor_passthrough_display_names()): + checks.append(DoctorCheck("Cursor passthrough", "INFO", f"exposed model: {slug}")) + else: + checks.append( + DoctorCheck( + "Cursor passthrough", + "WARN", + "unavailable", + "Run `cursor-agent login` if you want Cursor passthrough.", + ) + ) + return checks + + +def _doctor_proxy_env() -> list[DoctorCheck]: + required = {"127.0.0.1", "localhost", "::1"} + values: set[str] = set() + for key in ("NO_PROXY", "no_proxy"): + raw = os.environ.get(key, "") + for part in raw.split(","): + value = part.strip().lower() + if value: + values.add(value) + if "*" in values or required <= values: + return [DoctorCheck("Proxy", "OK", "loopback hosts covered by NO_PROXY/no_proxy")] + return [ + DoctorCheck( + "Proxy", + "WARN", + "NO_PROXY/no_proxy does not include all loopback hosts", + "Recommended: 127.0.0.1,localhost,::1", + ) + ] + + +def _doctor_codex_config() -> list[DoctorCheck]: + path = Path(CODEX_CONFIG_PATH).expanduser() + if not path.exists(): + return [ + DoctorCheck( + "Codex config", + "INFO", + "shim provider is not currently installed", + "Run `codex-shim app .` or `codex-shim enable` to wire Codex to the shim.", + ) + ] + checks = [DoctorCheck("Codex config", "OK", f"config exists: {path}")] + try: + text = path.read_text() + except OSError as exc: + return [DoctorCheck("Codex config", "WARN", f"could not read config: {path}", str(exc))] + provider_configured = ( + f'model_provider = "{PROVIDER_NAME}"' in text or f"[model_providers.{PROVIDER_NAME}]" in text + ) + if provider_configured: + checks.append(DoctorCheck("Codex config", "OK", "shim provider configured")) + else: + checks.append( + DoctorCheck( + "Codex config", + "INFO", + "shim provider is not currently installed", + "Run `codex-shim app .` or `codex-shim enable` to wire Codex to the shim.", + ) + ) + current = _current_managed_model() + if current: + checks.append(DoctorCheck("Codex config", "OK", f"active shim model: {current}")) + else: + checks.append(DoctorCheck("Codex config", "INFO", "active shim model: none")) + return checks + + +def _print_doctor_report(checks: list[DoctorCheck]) -> None: + current_section = None + for check in checks: + if check.section != current_section: + if current_section is not None: + print() + print(check.section) + current_section = check.section + print(f" {check.status:<5} {check.message}") + if check.detail: + for line in check.detail.splitlines(): + print(f" {line}") + counts = Counter(check.status for check in checks) + summary_status = "FAIL" if counts["FAIL"] else "OK" + print() + print("Summary") + print( + f" {summary_status:<5} " + f"{counts['OK']} ok, {counts['WARN']} warn, {counts['FAIL']} fail, {counts['INFO']} info" + ) + + +def _env_flag(name: str) -> bool: + return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"} + + +def _bool_text(value) -> str: + return "true" if bool(value) else "false" + + def generate(settings_path: Path, port: int) -> None: models = _load_models(settings_path) try: diff --git a/tests/test_cli_doctor.py b/tests/test_cli_doctor.py new file mode 100644 index 00000000..6538ffff --- /dev/null +++ b/tests/test_cli_doctor.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace + +import pytest + +from codex_shim import cli + + +@pytest.fixture(autouse=True) +def doctor_safe_environment(monkeypatch, tmp_path): + monkeypatch.setattr(cli, "CATALOG_PATH", tmp_path / "custom_model_catalog.json") + monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / "config.toml") + monkeypatch.setattr(cli, "PID_PATH", tmp_path / "shim.pid") + monkeypatch.setattr(cli, "LOG_PATH", tmp_path / "shim.log") + monkeypatch.setattr(cli, "CODEX_CONFIG_PATH", tmp_path / "codex-config.toml") + monkeypatch.setattr(cli, "DEFAULT_CODEX_AUTH", tmp_path / "auth.json") + monkeypatch.setattr("codex_shim.settings.DEFAULT_CURSOR_API_KEY_FILE", tmp_path / "missing-cursor-api-key") + monkeypatch.delenv("CURSOR_API_KEY", raising=False) + monkeypatch.delenv("CURSOR_AGENT_BIN", raising=False) + monkeypatch.delenv("CODEX_SHIM_DISABLE_CHATGPT", raising=False) + monkeypatch.delenv("CODEX_SHIM_DISABLE_CURSOR", raising=False) + monkeypatch.delenv("CODEX_SHIM_DISABLE_ROUTER", raising=False) + monkeypatch.setenv("NO_PROXY", "127.0.0.1,localhost,::1") + monkeypatch.delenv("no_proxy", raising=False) + monkeypatch.setattr(cli.importlib.util, "find_spec", lambda name: object()) + monkeypatch.setattr(cli.shutil, "which", lambda command: None) + monkeypatch.setattr(cli, "_health", lambda port: None) + monkeypatch.setattr(cli, "_read_pid", lambda: None) + monkeypatch.setattr(cli, "_pid_running", lambda pid: False) + monkeypatch.setattr( + cli, + "available_model_slugs", + lambda models: {model.slug for model in models if model.api_key.strip()}, + ) + monkeypatch.setattr(cli, "chatgpt_passthrough_available", lambda: False) + monkeypatch.setattr(cli, "cursor_passthrough_available", lambda: False) + + +def _settings(path, models, router=None): + data = {"models": models} + if router is not None: + data["router"] = router + path.write_text(json.dumps(data)) + return path + + +def test_doctor_command_returns_zero_with_healthy_mocked_environment(monkeypatch, tmp_path, capsys): + settings = _settings( + tmp_path / "models.json", + [ + { + "model": "claude-upstream", + "display_name": "Claude Upstream", + "provider": "anthropic", + "base_url": "https://example.invalid/v1", + "api_key": "secret-anthropic", + }, + { + "model": "gpt-upstream", + "display_name": "GPT Upstream", + "provider": "generic-chat-completion-api", + "base_url": "https://example.invalid/v1", + "api_key": "secret-openai", + }, + ], + router={ + "enabled": True, + "slug": "codex-auto", + "classifier": "gpt-upstream", + "candidates": [{"slug": "claude-upstream"}], + }, + ) + monkeypatch.setattr( + cli.shutil, + "which", + lambda command: {"codex": "/usr/local/bin/codex", "cursor-agent": "/usr/local/bin/cursor-agent"}.get(command), + ) + monkeypatch.setattr( + cli.subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace(returncode=0, stdout="codex-cli 0.133.0-alpha.1\n", stderr=""), + ) + monkeypatch.setattr(cli, "_read_pid", lambda: 12345) + monkeypatch.setattr(cli, "_pid_running", lambda pid: pid == 12345) + monkeypatch.setattr( + cli, + "_health", + lambda port: { + "ok": True, + "models": 4, + "chatgpt_passthrough": True, + "cursor_passthrough": True, + "auto_router": True, + }, + ) + monkeypatch.setattr(cli, "chatgpt_passthrough_available", lambda: True) + monkeypatch.setattr(cli, "cursor_passthrough_available", lambda: True) + + code = cli.main(["--settings", str(settings), "--port", "8765", "doctor"]) + + out = capsys.readouterr().out + assert code == 0 + for section in ( + "Python", + "Dependencies", + "Codex CLI", + "Settings", + "Runtime files", + "Shim daemon", + "ChatGPT passthrough", + "Cursor passthrough", + "Proxy", + "Codex config", + "Summary", + ): + assert section in out + assert "health ok: 4 models" in out + assert "auto router active: codex-auto" in out + assert "secret-anthropic" not in out + assert "secret-openai" not in out + + +def test_invalid_settings_json_returns_one(tmp_path, capsys): + settings = tmp_path / "broken.json" + settings.write_text('{"models": [') + + code = cli.main(["--settings", str(settings), "doctor"]) + + out = capsys.readouterr().out + assert code == 1 + assert "invalid JSON" in out + assert "Summary" in out + + +def test_missing_settings_file_warns_without_failing(tmp_path, capsys): + settings = tmp_path / "missing.json" + + code = cli.main(["--settings", str(settings), "doctor"]) + + out = capsys.readouterr().out + assert code == 0 + assert "settings file not found" in out + assert "Summary" in out + + +def test_missing_api_key_is_warn(tmp_path, capsys): + settings = _settings( + tmp_path / "models.json", + [ + { + "model": "missing-key-model", + "display_name": "Missing Key Model", + "provider": "generic-chat-completion-api", + "base_url": "https://example.invalid/v1", + } + ], + ) + + code = cli.main(["--settings", str(settings), "doctor"]) + + out = capsys.readouterr().out + assert code == 0 + assert "WARN models missing API keys: 1" in out + + +def test_no_proxy_complete_is_ok(monkeypatch, tmp_path, capsys): + settings = _settings(tmp_path / "models.json", []) + monkeypatch.setenv("NO_PROXY", "127.0.0.1,localhost,::1") + monkeypatch.delenv("no_proxy", raising=False) + + code = cli.main(["--settings", str(settings), "doctor"]) + + out = capsys.readouterr().out + assert code == 0 + assert "loopback hosts covered by NO_PROXY/no_proxy" in out + assert "NO_PROXY/no_proxy does not include all loopback hosts" not in out + + +def test_no_proxy_missing_is_warn(monkeypatch, tmp_path, capsys): + settings = _settings(tmp_path / "models.json", []) + monkeypatch.delenv("NO_PROXY", raising=False) + monkeypatch.delenv("no_proxy", raising=False) + + code = cli.main(["--settings", str(settings), "doctor"]) + + out = capsys.readouterr().out + assert code == 0 + assert "NO_PROXY/no_proxy does not include all loopback hosts" in out + + +def test_daemon_health_output(monkeypatch, tmp_path, capsys): + settings = _settings(tmp_path / "models.json", []) + monkeypatch.setattr( + cli, + "_health", + lambda port: { + "ok": True, + "models": 2, + "chatgpt_passthrough": True, + "cursor_passthrough": False, + "auto_router": True, + }, + ) + + code = cli.main(["--settings", str(settings), "--port", "8765", "doctor"]) + + out = capsys.readouterr().out + assert code == 0 + assert "health ok: 2 models" in out + assert "chatgpt_passthrough: true" in out + assert "cursor_passthrough: false" in out + assert "auto_router: true" in out + + +def test_codex_cli_not_found_is_warn_not_fail(monkeypatch, tmp_path, capsys): + settings = _settings(tmp_path / "models.json", []) + monkeypatch.setattr(cli.shutil, "which", lambda command: None) + + code = cli.main(["--settings", str(settings), "doctor"]) + + out = capsys.readouterr().out + assert code == 0 + assert "codex not found on PATH" in out + + +def test_aiohttp_missing_is_fail(monkeypatch, tmp_path, capsys): + settings = _settings(tmp_path / "models.json", []) + monkeypatch.setattr( + cli.importlib.util, + "find_spec", + lambda name: None if name == "aiohttp" else object(), + ) + + code = cli.main(["--settings", str(settings), "doctor"]) + + out = capsys.readouterr().out + assert code == 1 + assert "aiohttp is not importable" in out + assert "FAIL" in out + + +def test_codex_config_installed_reports_provider_and_active_model(tmp_path, capsys): + settings = _settings(tmp_path / "models.json", []) + cli.CODEX_CONFIG_PATH.write_text( + "\n".join( + [ + cli.MANAGED_BEGIN, + 'model = "gpt-5.5"', + 'model_provider = "codex_shim"', + cli.MANAGED_END, + "[model_providers.codex_shim]", + 'name = "codex_shim"', + ] + ) + ) + + code = cli.main(["--settings", str(settings), "doctor"]) + + out = capsys.readouterr().out + assert code == 0 + assert "shim provider configured" in out + assert "active shim model: gpt-5.5" in out + + +def test_codex_config_uninstalled_is_info(tmp_path, capsys): + settings = _settings(tmp_path / "models.json", []) + + code = cli.main(["--settings", str(settings), "doctor"]) + + out = capsys.readouterr().out + assert code == 0 + assert "shim provider is not currently installed" in out