Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<hash>` is the exact commit the build came
from, so include it when reporting a bug.

---

## Usage
Expand All @@ -39,6 +43,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
Expand Down
28 changes: 23 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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 = [
Expand Down
217 changes: 213 additions & 4 deletions src/ucode/agents/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -13,6 +16,7 @@
backup_existing_file,
deep_merge_dict,
read_toml_safe,
restore_file,
write_toml_file,
)
from ucode.databricks import (
Expand All @@ -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"
Expand All @@ -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",
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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?")
Loading
Loading