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
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Unity AI Gateway Coding CLI (ucode)

`ucode` is a lightweight launcher for running Codex, Claude Code, Gemini CLI, OpenCode, GitHub Copilot CLI, and Pi through Databricks.
`ucode` is a lightweight launcher for running Codex, Claude Code, Gemini CLI, OpenCode, GitHub Copilot CLI, Pi, and Continue through Databricks.

## Requirements

Expand Down Expand Up @@ -30,6 +30,7 @@ ucode gemini # Gemini CLI
ucode opencode # OpenCode
ucode copilot # GitHub Copilot CLI
ucode pi # Pi
ucode continue # Continue (cn CLI + VS Code/JetBrains extensions)
ucode cursor # Cursor Agent (MCP only — see below)
```

Expand Down Expand Up @@ -73,7 +74,9 @@ To configure specific tools without the picker, pass a comma-separated list:
ucode configure --agents claude,codex
```

Available agent names are `codex`, `claude`, `gemini`, `opencode`, `copilot`, and `pi`. `cursor` is also accepted (MCP-only — it registers Databricks MCP servers but configures no models).
Available agent names are `codex`, `claude`, `gemini`, `opencode`, `copilot`, `pi`, and `continue`. `cursor` is also accepted (MCP-only — it registers Databricks MCP servers but configures no models).

`continue` writes `~/.continue/config.yaml`, which is shared by the `cn` CLI and the Continue VS Code/JetBrains extensions — so configuring it once wires up both. Its chat/edit/apply roles route to your Databricks Claude/GPT models through the Unity AI Gateway.

Naming agents explicitly is treated as a request for all of them: if any one isn't available on the workspace, the run fails without configuring the others. Add `--skip-unavailable` to configure the available subset instead and skip the rest with a warning:

Expand Down Expand Up @@ -111,7 +114,7 @@ ucode configure --profiles DEFAULT --agents claude,codex --use-pat --skip-valida
ucode configure mcp
```

Add Databricks MCP servers to installed MCP-capable tools: Codex, Claude Code, Gemini CLI, OpenCode, GitHub Copilot CLI, and Cursor Agent.
Add Databricks MCP servers to installed MCP-capable tools: Codex, Claude Code, Gemini CLI, OpenCode, GitHub Copilot CLI, Continue, and Cursor Agent.
Options are shown in this order:

- Discovered external MCP connections
Expand Down Expand Up @@ -390,6 +393,7 @@ control the installation.
| `~/.config/opencode/opencode.json` | OpenCode |
| `~/.copilot/.env` | GitHub Copilot CLI |
| `~/.pi/agent/models.json` | Pi |
| `~/.continue/config.yaml` | Continue (`cn` CLI + IDE extensions) |
| `~/.cursor/mcp.json` | Cursor Agent (MCP servers only) |
| `~/.ucode/managed-state.json` | The managed config — authored by `ucode setup` (admins) and refreshed from the workspace on launch |
| `~/.ucode/managed-backups/` | Baseline backups for OS-managed files changed by ucode |
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ dependencies = [
# request, via the SDK's stdio server + streamable-HTTP client. Works against
# both mcp 1.x (httpx) and mcp 2.x (httpx2) — see mcp_proxy for the shared path.
"mcp>=1.28.0",
"pyyaml>=6.0",
"questionary>=2.0.0",
"tomlkit>=0.13.0",
"typer>=0.12.0",
Expand Down
14 changes: 12 additions & 2 deletions src/ucode/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
spinner,
)

from . import claude, codex, copilot, gemini, opencode, pi
from . import claude, codex, continue_dev, copilot, gemini, opencode, pi
from .args import explicit_model_arg_value as explicit_model_arg_value

_MODULES = {
Expand All @@ -49,6 +49,7 @@
"opencode": opencode,
"copilot": copilot,
"pi": pi,
"continue": continue_dev,
}

TOOL_SPECS: dict[str, ToolSpec] = {name: module.SPEC for name, module in _MODULES.items()}
Expand All @@ -67,6 +68,9 @@
"opencode": "opencode",
"copilot": "copilot",
"pi": "pi",
"continue": "continue",
"continue-dev": "continue",
"cn": "continue",
}

DEFAULT_TOOL = "codex"
Expand Down Expand Up @@ -103,7 +107,8 @@ def normalize_tool(tool: str) -> str:
normalized = TOOL_ALIASES.get(tool.strip().lower())
if not normalized:
raise RuntimeError(
f"Unsupported tool '{tool}'. Use one of: codex, claude, gemini, opencode, copilot, pi."
f"Unsupported tool '{tool}'. Use one of: codex, claude, gemini, opencode, copilot, "
"pi, continue."
)
return normalized

Expand Down Expand Up @@ -350,6 +355,8 @@ def configure_tool(
result = copilot.write_tool_config(state, model)
elif tool == "pi":
result = pi.write_tool_config(state, model)
elif tool == "continue":
result = continue_dev.write_tool_config(state, model)
else:
result = opencode.write_tool_config(state, model)
# gemini/opencode/copilot/pi return (state, token); codex/claude return state
Expand Down Expand Up @@ -380,6 +387,8 @@ def check_gateway_endpoint(state: dict, tool: str) -> bool:
or bool(state.get("codex_models"))
or bool(state.get("gemini_models"))
)
if tool == "continue":
return bool(state.get("claude_models")) or bool(state.get("codex_models"))
return False


Expand All @@ -390,6 +399,7 @@ def check_gateway_endpoint(state: dict, tool: str) -> bool:
"gemini": ("gemini",),
"copilot": ("claude", "codex"),
"pi": ("claude", "codex", "gemini"),
"continue": ("claude", "codex"),
}


Expand Down
272 changes: 272 additions & 0 deletions src/ucode/agents/continue_dev.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
"""Continue agent: writes ~/.continue/config.yaml with a Databricks-backed model.

Continue.dev's `cn` CLI and its VS Code/JetBrains extensions read the same
`~/.continue/config.yaml`, so the file ucode writes configures both. We point
Continue's OpenAI-compatible provider at the Databricks MLflow chat-completions
gateway (the same endpoint Copilot uses), which serves Claude and codex (gpt-5)
models behind one URL. `provider: openai` makes Continue append
`/chat/completions` to the configured `apiBase`.

The gateway bearer token is baked into the config file (Continue has no
command-based auth refresh), so — like OpenCode/Copilot/Gemini — the token is
short-lived: `ucode continue` rewrites a fresh one on every launch and a
background thread refreshes it during the session. This is why Continue is not
in `GLOBAL_SETTINGS_AGENTS`: a bare `cn` launched outside ucode would eventually
see an expired token.
"""

from __future__ import annotations

import os
import signal
import subprocess
import threading
from pathlib import Path
from typing import cast

from ucode.config_io import (
APP_DIR,
ToolSpec,
backup_existing_file,
read_yaml_safe,
write_yaml_file,
)
from ucode.databricks import (
TOKEN_REFRESH_INTERVAL_SECONDS,
build_continue_base_url,
get_databricks_token,
)
from ucode.state import mark_tool_managed, save_state
from ucode.telemetry import agent_version, ucode_version

CONTINUE_CONFIG_DIR = Path.home() / ".continue"
CONTINUE_CONFIG_PATH = CONTINUE_CONFIG_DIR / "config.yaml"
CONTINUE_BACKUP_PATH = APP_DIR / "continue-ucode-config.backup.yaml"

# ucode's model entries in the shared `models:` list carry this name prefix so a
# rewrite can drop the stale ones without disturbing models the user added.
UCODE_MODEL_NAME_PREFIX = "Databricks (ucode)"

SPEC: ToolSpec = {
"binary": "cn",
"package": "@continuedev/cli",
"display": "Continue",
"config_path": CONTINUE_CONFIG_PATH,
"backup_path": CONTINUE_BACKUP_PATH,
}

# Informational: Continue's config is a list-shaped YAML document, so revert
# restores the backup (or deletes the ucode-created file) rather than pruning
# key paths. Recorded so `restore_file` sees the tool as managed.
MANAGED_KEYS: list[list[str]] = [["models"]]


def default_model(state: dict) -> str | None:
"""Pick the best available Continue model.

A managed config's ``continue_default_model`` wins outright. Otherwise prefer
Claude sonnet, then opus/haiku, then the first codex model — the same order
Copilot uses, since both draw from the shared chat-completions gateway.
"""
if isinstance(state.get("continue_default_model"), str):
return state.get("continue_default_model")
claude_models = state.get("claude_models") or {}
for family in ("sonnet", "opus", "haiku"):
if claude_models.get(family):
return claude_models[family]
codex_models = state.get("codex_models") or []
if codex_models:
return codex_models[0]
return None


def _ucode_model_entry(model: str, token: str, workspace: str) -> dict:
"""Build the ucode-managed `models:` entry for chat/edit/apply roles."""
return {
"name": f"{UCODE_MODEL_NAME_PREFIX} {model}",
"provider": "openai",
"model": model,
"apiBase": build_continue_base_url(workspace),
"apiKey": token,
"roles": ["chat", "edit", "apply"],
"requestOptions": {
"headers": {
"User-Agent": f"ucode/{ucode_version()} continue/{agent_version('cn')}",
},
},
}


def _ensure_schema_header(doc: dict) -> None:
"""Set Continue's required top-level keys, keeping the user's if present."""
doc.setdefault("name", "ucode")
doc.setdefault("version", "0.0.1")
doc.setdefault("schema", "v1")


def _is_ucode_model(entry: object) -> bool:
"""True for a ucode-managed `models:` entry (identified by its name prefix)."""
if not isinstance(entry, dict):
return False
name = cast("dict[str, object]", entry).get("name", "")
return isinstance(name, str) and name.startswith(UCODE_MODEL_NAME_PREFIX)


def write_tool_config(
state: dict,
model: str,
token: str | None = None,
*,
force_refresh: bool = False,
) -> tuple[dict, str]:
backup_existing_file(CONTINUE_CONFIG_PATH, CONTINUE_BACKUP_PATH)
if token is None:
token = get_databricks_token(
state["workspace"], state.get("profile"), force_refresh=force_refresh
)
existing = read_yaml_safe(CONTINUE_CONFIG_PATH)
_ensure_schema_header(existing)
models = existing.get("models")
if not isinstance(models, list):
models = []
# Drop any prior ucode entry so a rewrite replaces (not duplicates) it, while
# leaving the user's own models untouched.
models = [m for m in models if not _is_ucode_model(m)]
models.append(_ucode_model_entry(model, token, state["workspace"]))
existing["models"] = models
write_yaml_file(CONTINUE_CONFIG_PATH, existing)
state = mark_tool_managed(state, "continue", MANAGED_KEYS)
save_state(state)
return state, token


def build_mcp_server_entry(name: str, argv: list[str]) -> dict:
# A local stdio MCP server: `command`/`args` run the `ucode mcp-proxy ...`
# bridge, which mints a fresh OAuth token per request — so Continue never
# speaks HTTP+bearer directly (same proxy pattern as Cursor/OpenCode).
# Continue's `mcpServers` is a list, so each entry carries its own `name`.
return {
"name": name,
"type": "stdio",
"command": argv[0],
"args": list(argv[1:]),
}


def write_mcp_server_config(name: str, argv: list[str]) -> bool:
backup_existing_file(CONTINUE_CONFIG_PATH, CONTINUE_BACKUP_PATH)
existing = read_yaml_safe(CONTINUE_CONFIG_PATH)
_ensure_schema_header(existing)
servers = existing.get("mcpServers")
if not isinstance(servers, list):
servers = []
# `mcpServers` is a list keyed by `name`; drop any prior entry with this name
# so a re-register replaces it, leaving the user's own servers untouched.
removed = any(isinstance(s, dict) and s.get("name") == name for s in servers)
servers = [s for s in servers if not (isinstance(s, dict) and s.get("name") == name)]
servers.append(build_mcp_server_entry(name, argv))
existing["mcpServers"] = servers
write_yaml_file(CONTINUE_CONFIG_PATH, existing)
return removed


def remove_mcp_server_config(name: str) -> bool:
existing = read_yaml_safe(CONTINUE_CONFIG_PATH)
servers = existing.get("mcpServers")
if not isinstance(servers, list):
return False
filtered = [s for s in servers if not (isinstance(s, dict) and s.get("name") == name)]
if len(filtered) == len(servers):
return False
existing["mcpServers"] = filtered
write_yaml_file(CONTINUE_CONFIG_PATH, existing)
return True


def revert_config() -> bool:
"""Surgically remove ucode's model entries from the shared ``config.yaml``.

Continue's config is the user's real IDE config — unlike the other agents'
ucode-owned/isolated files — so a whole-file restore would clobber models the
user added since ucode first ran. Strip only ucode's own entries instead
(mirroring Codex's ``revert_legacy_shared_config``); the MCP servers ucode
registered are removed separately by ``mcp.revert_mcp_configs``. Returns True
if anything was removed.
"""
if not CONTINUE_CONFIG_PATH.exists():
return False
existing = read_yaml_safe(CONTINUE_CONFIG_PATH)
models = existing.get("models")
if not isinstance(models, list):
return False
kept = [m for m in models if not _is_ucode_model(m)]
if len(kept) == len(models):
return False
if kept:
existing["models"] = kept
else:
existing.pop("models", None)
write_yaml_file(CONTINUE_CONFIG_PATH, existing)
return True


def _refresh_token_once(state: dict, *, force_refresh: bool = False) -> str:
model = default_model(state)
if not model:
raise RuntimeError("No Continue model is available on this workspace.")
_, token = write_tool_config(state, model, force_refresh=force_refresh)
return token


def _refresh_forever(state: dict, stop_event: threading.Event) -> None:
while not stop_event.wait(TOKEN_REFRESH_INTERVAL_SECONDS):
try:
_refresh_token_once(state, force_refresh=True)
except RuntimeError:
continue


def build_runtime_env(token: str) -> dict[str, str]:
env = os.environ.copy()
env["OAUTH_TOKEN"] = token
return env


def launch(state: dict, tool_args: list[str]) -> None:
"""Launch `cn` with background token refresh (same pattern as OpenCode)."""
token = _refresh_token_once(state)
env = build_runtime_env(token)

stop_event = threading.Event()
refresher = threading.Thread(
target=_refresh_forever,
args=(state, stop_event),
daemon=True,
)
refresher.start()

# `--config` forces ucode's config over any hub assistant the user last
# selected, so `ucode continue` always routes through the Databricks gateway.
argv = [SPEC["binary"], "--config", str(CONTINUE_CONFIG_PATH), *tool_args]
proc = subprocess.Popen(argv, env=env)
try:
returncode = proc.wait()
except KeyboardInterrupt:
proc.send_signal(signal.SIGINT)
returncode = proc.wait()
finally:
stop_event.set()
refresher.join(timeout=1)

raise SystemExit(returncode)


def validate_cmd(binary: str) -> list[str]:
# `-p` runs headless (TTY-less); `--config` pins ucode's config for the probe.
return [
binary,
"-p",
"say hi in 5 words or less",
"--config",
str(CONTINUE_CONFIG_PATH),
]
Loading