diff --git a/README.md b/README.md index 0420190..ba3c35f 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,17 @@ ucode codex --full-auto All agents route through Databricks AI Gateway using your workspace credentials — no API keys required. +### Codex desktop app + +`ucode codex-app` points the Codex desktop app (macOS/Windows) at Databricks and opens it. The app reads only the root `~/.codex/config.toml`, so ucode backs that file up and edits it in place (unlike `ucode codex`, which uses an isolated profile). If Codex is already running, ucode offers to restart it so the change takes effect. + +```bash +ucode codex-app # configure + open the desktop app +ucode codex-app --restore # put the app's config back +``` + +`ucode revert` also restores the desktop app config. + To configure all tools at once: ```bash diff --git a/src/ucode/agents/codex.py b/src/ucode/agents/codex.py index 2a5c657..1379609 100644 --- a/src/ucode/agents/codex.py +++ b/src/ucode/agents/codex.py @@ -3,7 +3,10 @@ from __future__ import annotations import os +import platform import re +import subprocess +import time from pathlib import Path from ucode.agent_updates import available_npm_package_update @@ -13,6 +16,7 @@ backup_existing_file, deep_merge_dict, read_toml_safe, + restore_file, write_toml_file, ) from ucode.databricks import ( @@ -23,6 +27,7 @@ from ucode.launcher import exec_or_spawn from ucode.state import mark_tool_managed, save_state from ucode.telemetry import agent_version, ucode_version +from ucode.ui import print_note, print_success, print_warning, prompt_yes_no CODEX_CONFIG_DIR = Path.home() / ".codex" CODEX_PROFILE_NAME = "ucode" @@ -34,6 +39,27 @@ MINIMUM_CODEX_VERSION = (0, 134, 0) MINIMUM_CODEX_VERSION_TEXT = "0.134.0" +# ── Codex desktop app ────────────────────────────────────────────────────── +# The Codex desktop app has no `--profile`/`-c` command-line surface (unlike the +# CLI), so the only way to steer it is the *root* `~/.codex/config.toml` it reads +# on startup. `ucode codex-app` therefore edits that shared file in place — +# backing it up first — instead of writing an isolated per-profile file. A +# distinct provider name keeps the app's block from colliding with the CLI's +# `ucode-databricks` provider on the same file. +CODEX_APP_CONFIG_PATH = CODEX_CONFIG_DIR / "config.toml" +CODEX_APP_BACKUP_PATH = APP_DIR / "codex-app-config.backup.toml" +CODEX_APP_MODEL_PROVIDER_NAME = "ucode-databricks-app" +CODEX_APP_BUNDLE_ID = "com.openai.codex" +# Seconds to wait for the app to quit before reopening it (mirrors Ollama). +CODEX_APP_QUIT_TIMEOUT = 5.0 + +CODEX_APP_MANAGED_KEYS: list[list[str]] = [ + ["model_provider"], + ["model"], + ["model_providers", CODEX_APP_MODEL_PROVIDER_NAME], + ["model_providers", CODEX_APP_MODEL_PROVIDER_NAME, "http_headers"], +] + SPEC: ToolSpec = { "binary": "codex", @@ -139,14 +165,13 @@ def render_overlay( databricks_profile: str | None = None, use_pat: bool = False, provider: str | None = None, + provider_name: str = CODEX_MODEL_PROVIDER_NAME, ) -> dict: - overlay: dict = {"model_provider": CODEX_MODEL_PROVIDER_NAME} + overlay: dict = {"model_provider": provider_name} if model: overlay["model"] = model overlay["model_providers"] = { - CODEX_MODEL_PROVIDER_NAME: _provider_block( - workspace, databricks_profile, use_pat, provider - ), + provider_name: _provider_block(workspace, databricks_profile, use_pat, provider), } return overlay @@ -408,3 +433,187 @@ def validate_cmd(binary: str) -> list[str]: "--skip-git-repo-check", "say hi in 5 words or less", ] + + +# ── Codex desktop app config + launch ────────────────────────────────────── + + +def write_app_config(state: dict, model: str | None = None, provider: str | None = None) -> dict: + """Point the Codex desktop app at the Databricks gateway via its root config. + + The app reads only `~/.codex/config.toml`, so — unlike the CLI's isolated + per-profile file — we deep-merge ucode's provider block, `model_provider`, + and (unless routing through a provider) `model` into that shared file. The + file is backed up first so `ucode revert` / `ucode codex-app --restore` can + put the user's original config back. + """ + workspace = state["workspace"] + # A Model Provider Service routes by header, so pin no Databricks model. + chosen_model = None if provider else _codex_model_id(model or default_model(state)) + databricks_profile = state.get("profile") + + backup_existing_file(CODEX_APP_CONFIG_PATH, CODEX_APP_BACKUP_PATH) + overlay = render_overlay( + workspace, + chosen_model, + databricks_profile, + use_pat=bool(state.get("use_pat")), + provider=provider, + provider_name=CODEX_APP_MODEL_PROVIDER_NAME, + ) + doc = read_toml_safe(CODEX_APP_CONFIG_PATH) + deep_merge_dict(doc, overlay) + if provider: + # deep_merge can't drop keys, so clear a `model` pinned by an earlier + # non-provider run that the provider overlay omits. + doc.pop("model", None) + write_toml_file(CODEX_APP_CONFIG_PATH, doc) + state = mark_tool_managed(state, "codex-app", CODEX_APP_MANAGED_KEYS) + save_state(state) + return state + + +def revert_app_config(managed: bool = True) -> bool: + """Restore the desktop app's root config from ucode's backup. + + Returns True if the file was restored (or removed when ucode created it and + there was no prior config to back up). Mirrors how every other ucode tool + reverts — whole-file restore from the first-ever snapshot. + """ + return restore_file(CODEX_APP_CONFIG_PATH, CODEX_APP_BACKUP_PATH, managed) + + +def _app_is_running() -> bool: + system = platform.system() + try: + if system == "Darwin": + result = subprocess.run( + ["osascript", "-e", 'tell application "System Events" to exists process "Codex"'], + capture_output=True, + text=True, + timeout=10, + ) + return result.stdout.strip() == "true" + if system == "Windows": + result = subprocess.run( + ["tasklist", "/FI", "IMAGENAME eq Codex.exe"], + capture_output=True, + text=True, + timeout=10, + ) + return "Codex.exe" in result.stdout + except (OSError, subprocess.SubprocessError): + return False + return False + + +def _quit_app() -> None: + system = platform.system() + if system == "Darwin": + subprocess.run( + ["osascript", "-e", 'tell application "Codex" to quit'], + capture_output=True, + timeout=10, + ) + elif system == "Windows": + subprocess.run( + ["taskkill", "/IM", "Codex.exe"], + capture_output=True, + timeout=10, + ) + + +def _open_app(workdir: str | None = None) -> bool: + """Open the Codex desktop app, in ``workdir`` when given. + + A GUI app launched via ``open``/``start`` does not inherit the terminal's + working directory (the launch is handed to launchd / the shell), so Codex + would otherwise open its own default folder. Passing the directory as a + positional argument tells Codex which repo to open. + """ + system = platform.system() + try: + if system == "Darwin": + argv = ["open", "-b", CODEX_APP_BUNDLE_ID] + if workdir: + argv.append(workdir) + result = subprocess.run(argv, capture_output=True, timeout=15) + return result.returncode == 0 + if system == "Windows": + # `start` is a cmd builtin; launch the Codex app by name, passing the + # directory as an argument so it opens there. + argv = ["cmd", "/c", "start", "", "Codex.exe"] + if workdir: + argv.append(workdir) + result = subprocess.run(argv, capture_output=True, timeout=15) + return result.returncode == 0 + except (OSError, subprocess.SubprocessError): + return False + return False + + +def _warn_app_not_found() -> None: + print_warning( + "Couldn't open the Codex desktop app — is it installed? Install it from " + "https://developers.openai.com/codex, then re-run `ucode codex-app`. " + "Your config was written, so it will take effect the next time Codex starts." + ) + + +def _wait_for_app_exit(timeout: float) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not _app_is_running(): + return True + time.sleep(0.2) + return not _app_is_running() + + +def _restart_app(prompt: str, workdir: str | None = None) -> None: + """Quit and reopen the app (with confirmation) so it re-reads config.toml.""" + if not prompt_yes_no(prompt): + print_note("Quit and reopen Codex when you're ready for the change to take effect.") + return + _quit_app() + if not _wait_for_app_exit(CODEX_APP_QUIT_TIMEOUT): + print_warning("Codex did not quit; quit it manually, then reopen it to apply the change.") + return + if _open_app(workdir): + print_success("Codex restarted.") + else: + _warn_app_not_found() + + +def _ensure_app_supported() -> None: + if platform.system() not in ("Darwin", "Windows"): + raise RuntimeError( + "`ucode codex-app` is only supported on macOS and Windows. On Linux, use " + "`ucode codex` for the CLI instead." + ) + + +def launch_app(state: dict) -> None: + """Open the Codex desktop app, restarting it if it's already running. + + The app only re-reads `config.toml` on startup, so when it's already open we + prompt to quit + reopen; declining leaves the config written for next launch. + """ + _ensure_app_supported() + workdir = os.getcwd() + if not _app_is_running(): + if _open_app(workdir): + print_success("Codex is now configured to use Databricks. Opening the app...") + else: + _warn_app_not_found() + return + _restart_app("Codex is running. Restart it to use Databricks?", workdir) + + +def restart_app_after_restore() -> None: + """Prompt to restart a running app so it picks up a just-restored config. + + No-op when the app isn't running (it will read the restored config on its + next launch) or on unsupported platforms.""" + if platform.system() not in ("Darwin", "Windows") or not _app_is_running(): + return + _restart_app("Codex is running. Restart it to use your restored config?") diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 9ebdae9..d23fca5 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -27,6 +27,7 @@ from ucode.agents import ( launch as launch_agent, ) +from ucode.agents.codex import revert_app_config as revert_codex_app_config from ucode.agents.codex import revert_legacy_shared_config from ucode.agents.pi import PI_SETTINGS_BACKUP_PATH, PI_SETTINGS_PATH from ucode.config_io import restore_file, set_dry_run @@ -729,6 +730,9 @@ def revert() -> int: # Older Codex (< 0.134.0) had ucode edit the shared ~/.codex/config.toml in # place; restoring the per-profile file above does not undo that. legacy_codex_stripped = revert_legacy_shared_config() + # `ucode codex-app` edits the desktop app's root ~/.codex/config.toml in + # place; restore it from ucode's backup. + codex_app_restored = revert_codex_app_config(bool(managed_configs.get("codex-app"))) clear_state() print_heading("Revert") @@ -737,6 +741,8 @@ def revert() -> int: print_kv(f"{spec['display']} config", "restored" if results[tool] else "unchanged") if legacy_codex_stripped: print_kv("Codex shared config", "ucode entries removed") + if codex_app_restored: + print_kv("Codex desktop app config", "restored") print_kv("Pi settings", "restored" if pi_settings_restored else "unchanged") for client, spec in MCP_CLIENTS.items(): print_kv( @@ -948,6 +954,105 @@ def _launch_tool( ] +def _configure_codex_app(provider: str | None, skip_preflight: bool) -> None: + """Point the Codex desktop app at Databricks, then open/restart it. + + Unlike `ucode codex` (which writes an isolated ~/.codex/ucode.config.toml and + passes --profile), the desktop app reads only the root ~/.codex/config.toml, + so we back that file up and edit it in place. `ucode codex-app --restore` + (or `ucode revert`) undoes it. + """ + from ucode.agents import codex + + existing = load_state() + apply_pat_environment(existing) + if not existing.get("workspace") or "codex" not in (existing.get("available_tools") or []): + _auto_configure_tool("codex") + state = ensure_provider_state("codex") + provider = provider or get_provider_service(state, "codex") + provider_models = None + if provider: + provider_models, error = resolve_provider_models("codex", state, provider) + if error: + raise RuntimeError(error) + # Refresh models (skipped under a provider) so newly-added endpoints appear. + state = configure_shared_state( + state["workspace"], + profile=state.get("profile"), + tools=["codex"], + skip_model_discovery=bool(provider), + skip_preflight=skip_preflight, + ) + if provider: + resolved_model = None + else: + state, resolved_model = resolve_launch_model("codex", state, None) + state = codex.write_app_config(state, resolved_model, provider=provider) + + print_section("Codex desktop app") + if provider: + print_kv("Provider", provider) + elif resolved_model: + print_kv("Model", resolved_model) + print_kv("Config file", str(codex.CODEX_APP_CONFIG_PATH)) + codex.launch_app(state) + + +def _restore_codex_app() -> None: + """Restore the Codex desktop app's root config from ucode's backup.""" + from ucode.agents import codex + + state = load_state() + managed = bool((state.get("managed_configs") or {}).get("codex-app")) + restored = codex.revert_app_config(managed) + if managed: + state["managed_configs"].pop("codex-app", None) + save_state(state) + print_section("Restore Codex desktop app") + print_kv("Config file", "restored" if restored else "unchanged") + if restored: + codex.restart_app_after_restore() + else: + print_note("No ucode backup found; nothing to restore.") + + +@app.command( + "codex-app", context_settings={"allow_extra_args": True, "ignore_unknown_options": True} +) +def codex_app_cmd( + ctx: typer.Context, + provider: Annotated[ + str | None, + typer.Option( + "--provider", + help="Route through a Unity Catalog Model Provider Service " + "(..). Skips Databricks model pinning.", + ), + ] = None, + restore: Annotated[ + bool, + typer.Option( + "--restore", + help="Restore the Codex desktop app's config to what it was before " + "`ucode codex-app` last ran.", + ), + ] = False, + skip_preflight: SkipPreflightOption = False, +) -> None: + """Configure the Codex desktop app to use Databricks, then open it.""" + try: + if restore: + _restore_codex_app() + else: + _configure_codex_app(provider, skip_preflight) + except RuntimeError as exc: + print_err(str(exc)) + raise typer.Exit(1) from None + except KeyboardInterrupt: + print_err("Interrupted.") + raise typer.Exit(130) from None + + @app.command("codex", context_settings={"allow_extra_args": True, "ignore_unknown_options": True}) def codex_cmd( ctx: typer.Context, diff --git a/tests/test_agent_codex.py b/tests/test_agent_codex.py index 9a68e03..9f269ef 100644 --- a/tests/test_agent_codex.py +++ b/tests/test_agent_codex.py @@ -436,3 +436,151 @@ def fake_execvp(binary: str, args: list[str]) -> None: assert os.environ["OAUTH_TOKEN"] == "fresh-token" assert exec_calls == [("codex", ["codex", "--profile", "ucode", "--search"])] + + +class TestCodexAppWriteConfig: + def _patch_paths(self, tmp_path, monkeypatch): + config_path = tmp_path / ".codex" / "config.toml" + backup_path = tmp_path / "codex-app-config.backup.toml" + monkeypatch.setattr(codex, "CODEX_APP_CONFIG_PATH", config_path) + monkeypatch.setattr(codex, "CODEX_APP_BACKUP_PATH", backup_path) + monkeypatch.setattr(codex, "save_state", lambda state: None) + return config_path, backup_path + + def test_writes_root_config_with_app_provider(self, tmp_path, monkeypatch): + config_path, _ = self._patch_paths(tmp_path, monkeypatch) + + codex.write_app_config({"workspace": WS, "codex_models": ["gpt-5"]}) + + doc = read_toml_safe(config_path) + # The app reads root-level keys, not a [profiles.*] table. + assert doc["model_provider"] == "ucode-databricks-app" + assert doc["model"] == "gpt-5" + assert "profiles" not in doc + provider = doc["model_providers"]["ucode-databricks-app"] + assert provider["base_url"] == f"{WS}/ai-gateway/codex/v1" + assert provider["wire_api"] == "responses" + + def test_uses_distinct_provider_from_cli(self, tmp_path, monkeypatch): + # The app must not reuse the CLI provider name, so the two features can + # coexist on the same ~/.codex/config.toml without clobbering. + assert codex.CODEX_APP_MODEL_PROVIDER_NAME != codex.CODEX_MODEL_PROVIDER_NAME + + def test_backs_up_existing_config_before_editing(self, tmp_path, monkeypatch): + config_path, backup_path = self._patch_paths(tmp_path, monkeypatch) + config_path.parent.mkdir(parents=True) + config_path.write_text('model = "o1"\n[foo]\nbar = "baz"\n', encoding="utf-8") + + codex.write_app_config({"workspace": WS, "codex_models": ["gpt-5"]}) + + # Backup captures the pristine original. + assert 'model = "o1"' in backup_path.read_text(encoding="utf-8") + # The user's unrelated section is preserved by the deep merge. + assert read_toml_safe(config_path)["foo"]["bar"] == "baz" + + def test_marks_codex_app_managed(self, tmp_path, monkeypatch): + self._patch_paths(tmp_path, monkeypatch) + + state = codex.write_app_config({"workspace": WS, "codex_models": ["gpt-5"]}) + + assert "codex-app" in state["managed_configs"] + + def test_provider_drops_stale_model_and_adds_header(self, tmp_path, monkeypatch): + config_path, _ = self._patch_paths(tmp_path, monkeypatch) + + codex.write_app_config({"workspace": WS, "codex_models": ["gpt-5"]}) + assert read_toml_safe(config_path)["model"] == "gpt-5" + + codex.write_app_config( + {"workspace": WS, "codex_models": ["gpt-5"]}, + provider="main.aarushi.aarushi-openai", + ) + + doc = read_toml_safe(config_path) + assert "model" not in doc + headers = doc["model_providers"]["ucode-databricks-app"]["http_headers"] + assert headers["Databricks-Model-Provider-Service"] == "main.aarushi.aarushi-openai" + + +class TestCodexAppRevert: + def test_restores_from_backup(self, tmp_path, monkeypatch): + config_path = tmp_path / ".codex" / "config.toml" + backup_path = tmp_path / "codex-app-config.backup.toml" + config_path.parent.mkdir(parents=True) + config_path.write_text('model = "ucode-wrote-this"\n', encoding="utf-8") + backup_path.write_text('model = "original"\n', encoding="utf-8") + monkeypatch.setattr(codex, "CODEX_APP_CONFIG_PATH", config_path) + monkeypatch.setattr(codex, "CODEX_APP_BACKUP_PATH", backup_path) + + assert codex.revert_app_config(managed=True) is True + + assert 'model = "original"' in config_path.read_text(encoding="utf-8") + assert not backup_path.exists() + + def test_removes_config_when_managed_and_no_backup(self, tmp_path, monkeypatch): + # ucode created config.toml (no prior file to back up), so revert deletes it. + config_path = tmp_path / ".codex" / "config.toml" + backup_path = tmp_path / "codex-app-config.backup.toml" + config_path.parent.mkdir(parents=True) + config_path.write_text('model = "ucode-wrote-this"\n', encoding="utf-8") + monkeypatch.setattr(codex, "CODEX_APP_CONFIG_PATH", config_path) + monkeypatch.setattr(codex, "CODEX_APP_BACKUP_PATH", backup_path) + + assert codex.revert_app_config(managed=True) is True + assert not config_path.exists() + + def test_unchanged_when_not_managed_and_no_backup(self, tmp_path, monkeypatch): + config_path = tmp_path / ".codex" / "config.toml" + backup_path = tmp_path / "codex-app-config.backup.toml" + monkeypatch.setattr(codex, "CODEX_APP_CONFIG_PATH", config_path) + monkeypatch.setattr(codex, "CODEX_APP_BACKUP_PATH", backup_path) + + assert codex.revert_app_config(managed=False) is False + + +class TestCodexAppLaunch: + def test_unsupported_platform_raises(self, monkeypatch): + monkeypatch.setattr(codex.platform, "system", lambda: "Linux") + + try: + codex.launch_app({"workspace": WS}) + raise AssertionError("expected RuntimeError") + except RuntimeError as exc: + assert "macOS and Windows" in str(exc) + + def test_opens_app_in_cwd_when_not_running(self, tmp_path, monkeypatch): + opened: list[str | None] = [] + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(codex.platform, "system", lambda: "Darwin") + monkeypatch.setattr(codex, "_app_is_running", lambda: False) + monkeypatch.setattr(codex, "_open_app", lambda workdir=None: opened.append(workdir) or True) + + codex.launch_app({"workspace": WS}) + + # The app is opened in the directory ucode was run from, not its default. + assert opened == [str(tmp_path)] + + def test_prompts_restart_when_running(self, monkeypatch): + calls: list[str] = [] + monkeypatch.setattr(codex.platform, "system", lambda: "Darwin") + monkeypatch.setattr(codex, "_app_is_running", lambda: True) + monkeypatch.setattr(codex, "prompt_yes_no", lambda prompt: True) + monkeypatch.setattr(codex, "_quit_app", lambda: calls.append("quit")) + monkeypatch.setattr(codex, "_wait_for_app_exit", lambda timeout: True) + monkeypatch.setattr(codex, "_open_app", lambda workdir=None: calls.append("open") or True) + + codex.launch_app({"workspace": WS}) + + assert calls == ["quit", "open"] + + def test_declining_restart_leaves_app_alone(self, monkeypatch): + calls: list[str] = [] + monkeypatch.setattr(codex.platform, "system", lambda: "Darwin") + monkeypatch.setattr(codex, "_app_is_running", lambda: True) + monkeypatch.setattr(codex, "prompt_yes_no", lambda prompt: False) + monkeypatch.setattr(codex, "_quit_app", lambda: calls.append("quit")) + monkeypatch.setattr(codex, "_open_app", lambda workdir=None: calls.append("open") or True) + + codex.launch_app({"workspace": WS}) + + assert calls == []