From 030ecc824aaa2b839ca48bb89d726ea5d31cdabf Mon Sep 17 00:00:00 2001 From: AarushiShah-db Date: Tue, 14 Jul 2026 21:42:43 +0000 Subject: [PATCH 1/3] Add ucode codex-app to configure + launch the Codex desktop app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop app has no --profile/-c flags, so it only reads the root ~/.codex/config.toml. ucode codex-app backs that file up and edits it in place (distinct provider name so it coexists with the CLI's isolated profile), then opens the app — prompting to restart if it's already running. ucode codex-app --restore and ucode revert both undo it. Co-authored-by: Isaac --- README.md | 11 ++ src/ucode/agents/codex.py | 207 +++++++++++++++++++++++++++++++++++++- src/ucode/cli.py | 105 +++++++++++++++++++ tests/test_agent_codex.py | 146 +++++++++++++++++++++++++++ 4 files changed, 465 insertions(+), 4 deletions(-) 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..6608c64 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,177 @@ 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() -> bool: + """Open the Codex desktop app. Returns False when it couldn't be launched.""" + system = platform.system() + try: + if system == "Darwin": + result = subprocess.run( + ["open", "-b", CODEX_APP_BUNDLE_ID], capture_output=True, timeout=15 + ) + return result.returncode == 0 + if system == "Windows": + # `start` is a cmd builtin; launch the Codex app by name. + result = subprocess.run( + ["cmd", "/c", "start", "", "Codex.exe"], 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) -> 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(): + 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() + if not _app_is_running(): + if _open_app(): + 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?") + + +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..7c7ebc5 100644 --- a/tests/test_agent_codex.py +++ b/tests/test_agent_codex.py @@ -436,3 +436,149 @@ 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_when_not_running(self, monkeypatch): + opened: list[bool] = [] + monkeypatch.setattr(codex.platform, "system", lambda: "Darwin") + monkeypatch.setattr(codex, "_app_is_running", lambda: False) + monkeypatch.setattr(codex, "_open_app", lambda: opened.append(True) or True) + + codex.launch_app({"workspace": WS}) + + assert opened == [True] + + 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: 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: calls.append("open") or True) + + codex.launch_app({"workspace": WS}) + + assert calls == [] From 93986a8bd822d52180be14a1e856c56d652f6246 Mon Sep 17 00:00:00 2001 From: AarushiShah-db Date: Tue, 14 Jul 2026 21:57:41 +0000 Subject: [PATCH 2/3] codex-app: open the app in the terminal's working directory A GUI app launched via `open`/`start` doesn't inherit the shell's cwd, so Codex opened its own default folder instead of where `ucode codex-app` ran. Pass the cwd through so it opens the right repo. Co-authored-by: Isaac --- src/ucode/agents/codex.py | 36 +++++++++++++++++++++++------------- tests/test_agent_codex.py | 14 ++++++++------ 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/src/ucode/agents/codex.py b/src/ucode/agents/codex.py index 6608c64..1379609 100644 --- a/src/ucode/agents/codex.py +++ b/src/ucode/agents/codex.py @@ -523,20 +523,29 @@ def _quit_app() -> None: ) -def _open_app() -> bool: - """Open the Codex desktop app. Returns False when it couldn't be launched.""" +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": - result = subprocess.run( - ["open", "-b", CODEX_APP_BUNDLE_ID], capture_output=True, timeout=15 - ) + 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. - result = subprocess.run( - ["cmd", "/c", "start", "", "Codex.exe"], capture_output=True, timeout=15 - ) + # `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 @@ -560,7 +569,7 @@ def _wait_for_app_exit(timeout: float) -> bool: return not _app_is_running() -def _restart_app(prompt: str) -> None: +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.") @@ -569,7 +578,7 @@ def _restart_app(prompt: str) -> None: 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(): + if _open_app(workdir): print_success("Codex restarted.") else: _warn_app_not_found() @@ -590,13 +599,14 @@ def launch_app(state: dict) -> None: 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(): + 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?") + _restart_app("Codex is running. Restart it to use Databricks?", workdir) def restart_app_after_restore() -> None: diff --git a/tests/test_agent_codex.py b/tests/test_agent_codex.py index 7c7ebc5..9f269ef 100644 --- a/tests/test_agent_codex.py +++ b/tests/test_agent_codex.py @@ -548,15 +548,17 @@ def test_unsupported_platform_raises(self, monkeypatch): except RuntimeError as exc: assert "macOS and Windows" in str(exc) - def test_opens_app_when_not_running(self, monkeypatch): - opened: list[bool] = [] + 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: opened.append(True) or True) + monkeypatch.setattr(codex, "_open_app", lambda workdir=None: opened.append(workdir) or True) codex.launch_app({"workspace": WS}) - assert opened == [True] + # 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] = [] @@ -565,7 +567,7 @@ def test_prompts_restart_when_running(self, monkeypatch): 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: calls.append("open") or True) + monkeypatch.setattr(codex, "_open_app", lambda workdir=None: calls.append("open") or True) codex.launch_app({"workspace": WS}) @@ -577,7 +579,7 @@ def test_declining_restart_leaves_app_alone(self, monkeypatch): 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: calls.append("open") or True) + monkeypatch.setattr(codex, "_open_app", lambda workdir=None: calls.append("open") or True) codex.launch_app({"workspace": WS}) From 601c3bb0787215643f7db758a3e575f0b215c3e8 Mon Sep 17 00:00:00 2001 From: AarushiShah-db Date: Thu, 16 Jul 2026 21:41:34 +0000 Subject: [PATCH 3/3] Derive version from git tags + add `ucode --version` Replaces the static `0.1.0` in pyproject.toml (which never updated, so telemetry reported the same version forever) with a git-derived version via uv-dynamic-versioning. On a tag the version is the plain tag (e.g. `0.1.0`); between tags it carries the commit distance and short hash (e.g. `0.1.0+14.g93986a8`) so a reported version maps back to an exact commit. Adds `ucode --version` / `-V`, wired through the existing telemetry.ucode_version() so the CLI and the outbound User-Agent report the same string. Cut a release by tagging: `git tag v0.1.0 && git push origin v0.1.0`. Until the first `v*` tag exists the base is `0.0.0`. Co-authored-by: Isaac --- README.md | 4 ++++ pyproject.toml | 28 +++++++++++++++++++++++----- src/ucode/cli.py | 24 ++++++++++++++++++++++++ tests/test_cli.py | 17 +++++++++++++++++ 4 files changed, 68 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ba3c35f..f72ef94 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,10 @@ uv tool install git+https://github.com/databricks/ucode ``` +Check your version with `ucode --version`. Between releases this looks like +`0.1.0+14.g93986a8` — the trailing `g` is the exact commit the build came +from, so include it when reporting a bug. + --- ## Usage diff --git a/pyproject.toml b/pyproject.toml index 86bda87..a2eabde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,14 @@ [build-system] -requires = ["uv_build>=0.11.0"] -build-backend = "uv_build" +requires = ["hatchling", "uv-dynamic-versioning>=0.7.0"] +build-backend = "hatchling.build" [project] name = "ucode" -version = "0.1.0" +# Version is derived from git tags at build time (see [tool.uv-dynamic-versioning]). +# On a tag it is the plain tag (e.g. `0.1.0`); between tags it carries the commit +# count and short hash (e.g. `0.1.0+14.g93986a8`) so a reported version maps back +# to an exact commit. +dynamic = ["version"] description = "Bootstrap Codex against a Databricks workspace with minimal setup." readme = "README.md" requires-python = ">=3.12" @@ -24,8 +28,22 @@ tracing = ["mlflow[databricks]>=3.4"] [project.scripts] ucode = "ucode.cli:main" -[tool.uv.build-backend] -module-name = "ucode" +[tool.hatch.build.targets.wheel] +packages = ["src/ucode"] + +[tool.hatch.version] +source = "uv-dynamic-versioning" + +[tool.uv-dynamic-versioning] +vcs = "git" +# On a tagged commit: plain tag (e.g. `0.1.0`). Between tags: append the commit +# count and short hash as a local segment (e.g. `0.1.0+14.g93986a8`) so a +# reported version points back to an exact commit for bug reports. +format-jinja = "{% if distance == 0 %}{{ base }}{% else %}{{ base }}+{{ distance }}.g{{ commit }}{% endif %}" +# Used only when git metadata is unavailable at build time (e.g. building from a +# source archive with no `.git`). The normal install paths clone the repo, so +# this is a last-resort so builds degrade instead of hard-failing. +fallback-version = "0.0.0" [dependency-groups] dev = [ diff --git a/src/ucode/cli.py b/src/ucode/cli.py index d23fca5..cd27f7e 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -769,6 +769,30 @@ def revert() -> int: app.add_typer(mcp_app, name="mcp", help="MCP servers exposed by ucode.") +def _version_callback(value: bool) -> None: + if value: + from ucode.telemetry import ucode_version + + print(ucode_version()) + raise typer.Exit() + + +@app.callback() +def _main( + version: Annotated[ + bool, + typer.Option( + "--version", + "-V", + help="Show the ucode version and exit.", + callback=_version_callback, + is_eager=True, + ), + ] = False, +) -> None: + """Configure and launch coding agents through Databricks AI Gateway.""" + + @mcp_app.command("web-search") def mcp_web_search_cmd() -> None: """Run the web_search MCP server over stdio. Invoked as a subprocess by Claude Code.""" diff --git a/tests/test_cli.py b/tests/test_cli.py index 7bdb0e4..6bc582d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -90,6 +90,23 @@ def test_configure_help_lists_agents_flag(self): assert "--workspaces" in output +class TestVersion: + @pytest.mark.parametrize("flag", ["--version", "-V"]) + def test_prints_version_and_exits(self, flag): + result = runner.invoke(app, [flag]) + assert result.exit_code == 0 + # Matches the derived version reported by importlib.metadata — either a + # real string like "0.1.0" / "0.1.0+2.g93986a8" or the "unknown" fallback. + assert _strip_ansi(result.output).strip() != "" + + def test_matches_telemetry_version(self): + from ucode.telemetry import ucode_version + + result = runner.invoke(app, ["--version"]) + assert result.exit_code == 0 + assert ucode_version() in _strip_ansi(result.output) + + def _patch_launch(tool: str): """Return a context-manager stack that makes _launch_tool a no-op.