Skip to content

Commit 45db26a

Browse files
authored
Merge branch 'main' into fix/pi-config-dir-env
2 parents fa12598 + 41ec3f9 commit 45db26a

29 files changed

Lines changed: 4824 additions & 2239 deletions

.github/workflows/ci.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,23 @@ permissions:
1212
jobs:
1313
test:
1414
runs-on: ubuntu-latest
15+
env:
16+
# uv.lock pins packages to Databricks' internal pypi-proxy, which hosted
17+
# GitHub runners can't reach (downloads time out). Re-resolve against
18+
# public PyPI at CI time: UV_INDEX_URL + a `uv lock` before install.
19+
UV_INDEX_URL: https://pypi.org/simple
1520
steps:
1621
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
1722
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
23+
- run: uv lock
1824
- run: uv run pytest --ignore=tests/test_e2e.py --ignore=tests/test_e2e_tracing.py
1925

2026
e2e:
2127
runs-on: ubuntu-latest
2228
env:
29+
# See the test job: hosted runners can't reach the internal pypi-proxy,
30+
# so resolve against public PyPI (paired with the `uv lock` step below).
31+
UV_INDEX_URL: https://pypi.org/simple
2332
UCODE_TEST_WORKSPACE: ${{ secrets.UCODE_TEST_WORKSPACE }}
2433
DATABRICKS_HOST: ${{ secrets.UCODE_TEST_WORKSPACE }}
2534
# DATABRICKS_BEARER is the CI escape hatch: `databricks auth token`
@@ -47,6 +56,7 @@ jobs:
4756
opencode-ai
4857
@github/copilot
4958
@earendil-works/pi-coding-agent
59+
- run: uv lock
5060
- run: uv tool install .
5161
# Redirect stdin so any interactive `databricks auth login --no-browser`
5262
# fallback EOFs instead of hanging the runner. With DATABRICKS_BEARER

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Tests live in `tests/`.
2020

2121
- Use Python 3.12+.
2222
- Keep changes scoped to the requested behavior.
23-
- Follow the existing module boundaries: CLI orchestration in `cli.py`, agent-specific behavior in `agents/<name>.py`, shared agent dispatch in `agents/__init__.py`, Databricks calls in `databricks.py`, skill download (UC fetch client + on-disk writer + download orchestration) in `skills_download.py`, MCP-connection state glue in `mcp.py`, and presentation helpers in `ui.py`. Skill download persists no disk state — it writes files to `--path` (or the home dir) and registers only the schema-less skills MCP connection.
23+
- Follow the existing module boundaries: CLI orchestration in `cli.py`, agent-specific behavior in `agents/<name>.py`, shared agent dispatch in `agents/__init__.py`, Databricks calls in `databricks.py`, skill download (UC fetch client + on-disk writer + download orchestration) in `skills_download.py`, MCP-connection state glue in `mcp.py`, and presentation helpers in `ui.py`. Skill download persists no disk state — it writes files to `--path` (or the home dir) and registers only the schema-less skills MCP connection. `ucode configure skills` with no `--location` (or `--mcp` with no `--location`) registers that schema-less connection without downloading anything.
2424
- Prefer existing helpers for config file writes, state persistence, UI messages, and Databricks authentication.
2525
- Add or update focused tests for behavior changes.
2626
- Do not modify generated or lock files unless the dependency graph intentionally changes.

README.md

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ ucode gemini # Gemini CLI
2626
ucode opencode # OpenCode
2727
ucode copilot # GitHub Copilot CLI
2828
ucode pi # Pi
29+
ucode cursor # Cursor Agent (MCP only — see below)
2930
```
3031

3132
On first launch, `ucode` will prompt for your Databricks workspace URL, authenticate, and configure that tool automatically. Subsequent launches go straight to the agent.
@@ -51,7 +52,7 @@ To configure specific tools without the picker, pass a comma-separated list:
5152
ucode configure --agents claude,codex
5253
```
5354

54-
Available agent names are `codex`, `claude`, `gemini`, `opencode`, `copilot`, and `pi`.
55+
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).
5556

5657
To configure without the workspace picker, pass a comma-separated list of workspaces:
5758

@@ -81,16 +82,26 @@ ucode configure --profiles DEFAULT --agents claude,codex --use-pat --skip-valida
8182
ucode configure mcp
8283
```
8384

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

8788
- Discovered external MCP connections
8889
- Databricks SQL
8990
- Managed Databricks MCPs (Vector Search, UC Functions, etc.)
9091
- Custom MCP server URL
9192

92-
Discovered external MCP connections are listed directly. MCP auth uses a Databricks token that
93-
`ucode` sets when launching each tool.
93+
Discovered external MCP connections are listed directly.
94+
95+
Every Databricks MCP server is registered as a local **stdio** server that runs `ucode mcp-proxy`
96+
— a small bridge (shipped with `ucode`) between the coding tool and the Databricks
97+
streamable-HTTP MCP endpoint. The proxy mints a fresh OAuth token from your Databricks CLI profile
98+
on every request, so MCP auth is handled uniformly for every client and never expires mid-session.
99+
The coding tool starts and stops the proxy as a child process; there's nothing extra to run.
100+
101+
**Cursor** is MCP-only: `cursor-agent` runs models on your own Cursor account, so `ucode`
102+
configures no models for it — it only registers Databricks MCP servers in `~/.cursor/mcp.json`
103+
(via the same proxy). Include it with `ucode configure --agents cursor` or pick it in
104+
`ucode configure mcp`, then launch with `ucode cursor`.
94105

95106
To set up an agent and its MCP server(s) in one command, pass `--mcp` with fully-qualified
96107
service name(s) to `ucode configure`:
@@ -104,25 +115,32 @@ then registers the servers); pass a comma-separated list to register several at
104115

105116
### Skills (optional)
106117

107-
Configure Unity Catalog Skills for your coding tools with `ucode configure skills`. It has two
108-
mutually-exclusive modes, both scoped by `--location <catalog>.<schema>` (comma-separated for
109-
multiple schemas):
118+
Configure Unity Catalog Skills for your coding tools with `ucode configure skills`:
110119

111120
```bash
112-
# Download mode (default): fetch every skill in the schema to disk.
121+
# Utility tools only: register the schema-less skills MCP connection, no download.
122+
ucode configure skills
123+
124+
# Download mode: fetch every skill in the schema to disk (and register the connection).
113125
ucode configure skills --location main.default --path /abs/project/dir
114126

115127
# MCP mode: expose the schema's skills as MCP tools instead of downloading.
116128
ucode configure skills --location main.default,ml.prod --mcp
117129
```
118130

119-
- **Download mode** writes each skill flat as `<leaf>/SKILL.md` (plus its bundled files) into both
120-
`.claude/skills/` and `.agents/skills/`. `--path` (an existing absolute directory) is optional;
121-
when omitted, skills are written under your home directory. Any pre-existing skill dir prompts
122-
before it's overwritten. It then registers a schema-less skills MCP connection (utility tools
123-
only), leaving any prior `--mcp` scope untouched.
124-
- **MCP mode** sets the connection's location set to exactly `<list>` (override-only) and rebuilds
125-
its `?schema=` URL; no files are downloaded and `--path` is rejected.
131+
- **Bare command** (no `--location`) registers the schema-less skills MCP connection — the
132+
cross-schema utility tools only — and downloads nothing. `--mcp` with no `--location` does the
133+
same.
134+
- **Download mode** (with `--location`, no `--mcp`) writes each skill flat as `<leaf>/SKILL.md`
135+
(plus its bundled files) into both `.claude/skills/` and `.agents/skills/`. `--path` (an existing
136+
absolute directory) is optional; when omitted, skills are written under your home directory. Any
137+
pre-existing skill dir prompts before it's overwritten. It then registers a schema-less skills
138+
MCP connection, leaving any prior `--mcp` scope untouched.
139+
- **MCP mode** (`--location … --mcp`) sets the connection's location set to exactly `<list>`
140+
(override-only) and rebuilds its `?schema=` URL; no files are downloaded and `--path` is rejected.
141+
142+
Each run prints the registered server, its URL, the configured agents, and its tools, and reminds
143+
you to run `ucode <agent>` (existing agent sessions need a restart before the MCP tools load).
126144

127145
---
128146

@@ -140,6 +158,7 @@ ucode configure skills --location main.default,ml.prod --mcp
140158
| `ucode configure --profiles DEFAULT --use-pat` | Authenticate with the profile's personal access token — no browser login |
141159
| `ucode configure --skip-validate` | Write configs without sending a test message through each agent |
142160
| `ucode configure --agents claude --mcp system.ai.slack` | Configure an agent and register its Databricks MCP server(s) in one command |
161+
| `ucode configure skills` | Register the skills MCP connection (utility tools only); no skills download |
143162
| `ucode configure skills --location main.default [--path <dir>]` | Download a schema's skills to disk (under `<dir>`, or your home dir) and register a schema-less skills MCP connection |
144163
| `ucode configure skills --location main.default --mcp` | Expose a schema's skills as MCP tools (override-only) instead of downloading |
145164

@@ -155,6 +174,7 @@ ucode configure skills --location main.default,ml.prod --mcp
155174
| `~/.config/opencode/opencode.json` | OpenCode |
156175
| `~/.copilot/.env` | GitHub Copilot CLI |
157176
| `~/.pi/agent/models.json` | Pi |
177+
| `~/.cursor/mcp.json` | Cursor Agent (MCP servers only) |
158178

159179
Existing files are backed up before being overwritten. `ucode revert` restores backups.
160180

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ readme = "README.md"
1010
requires-python = ">=3.12"
1111
dependencies = [
1212
"databricks-sql-connector>=3.6.0",
13+
# `ucode mcp-proxy` bridges a client's stdio MCP transport to a Databricks
14+
# streamable-HTTP MCP endpoint, injecting a freshly-minted OAuth bearer per
15+
# request. Uses the official MCP SDK's stdio server + streamable-HTTP client.
16+
"mcp>=1.28.0",
1317
"questionary>=2.0.0",
1418
"tomlkit>=0.13.0",
1519
"typer>=0.12.0",

src/ucode/agents/__init__.py

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@
5252

5353
TOOL_SPECS: dict[str, ToolSpec] = {name: module.SPEC for name, module in _MODULES.items()}
5454

55+
# Model-routing agents ucode configures end to end. Cursor is deliberately NOT
56+
# here: it runs models on the user's own Cursor account, so `normalize_tool`
57+
# rejects it and the model-config paths never see it. The `configure`/MCP flows
58+
# handle "cursor" separately as an MCP-only client (see MCP_ONLY_CLIENTS).
5559
TOOL_ALIASES = {
5660
"codex": "codex",
5761
"claude": "claude",
@@ -278,25 +282,27 @@ def resolve_launch_model(
278282

279283
def resolve_provider_models(
280284
tool: str, state: dict, provider: str | None
281-
) -> tuple[dict | None, str | None]:
285+
) -> tuple[dict | None, str | None, bool]:
282286
"""Validate ``provider`` for ``tool`` and return the model ids to pin.
283287
284-
Returns ``(provider_models, error)``. ``provider_models`` is a
288+
Returns ``(provider_models, error, relayed)``. ``provider_models`` is a
285289
``{family: model_id}`` dict for a Bedrock-backed claude service (whose
286290
provider-side ids must be pinned explicitly), or None for an Anthropic/
287-
canonical service or when ``provider`` is None. A non-None ``error`` means
288-
the provider is invalid for the tool (wrong type, missing, feature off, or a
289-
Bedrock service with no Claude models) and the caller should not launch.
291+
canonical service or when ``provider`` is None. ``relayed`` is True for a
292+
credential-less Anthropic subscription relay, which the launch path wires
293+
with the relayed overlay + refresh proxy. A non-None ``error`` means the
294+
provider is invalid for the tool and the caller should not launch.
290295
"""
291296
if not provider:
292-
return None, None
297+
return None, None, False
293298
token = get_databricks_token(state["workspace"], state.get("profile"))
294299
service, error = resolve_provider_service(tool, provider, state["workspace"], token)
295300
if error or service is None:
296-
return None, error
301+
return None, error, False
302+
relayed = bool(service.get("relayed"))
297303
if service["provider_type"] in BEDROCK_PROVIDER_TYPES:
298-
return map_bedrock_claude_models(service.get("targets") or []), None
299-
return None, None
304+
return map_bedrock_claude_models(service.get("targets") or []), None, relayed
305+
return None, None, relayed
300306

301307

302308
def configure_tool(
@@ -305,6 +311,7 @@ def configure_tool(
305311
model: str | None = None,
306312
provider: str | None = None,
307313
provider_models: dict[str, str] | None = None,
314+
relayed: bool = False,
308315
) -> dict:
309316
result: dict | tuple[dict, str]
310317
if tool == "codex":
@@ -315,7 +322,7 @@ def configure_tool(
315322
if not model and not provider:
316323
raise RuntimeError(f"A {tool} model must be selected before configuration.")
317324
result = claude.write_tool_config(
318-
state, model, provider=provider, provider_models=provider_models
325+
state, model, provider=provider, provider_models=provider_models, relayed=relayed
319326
)
320327
else:
321328
# provider routing is claude/codex-only; every other tool needs a model.
@@ -405,10 +412,12 @@ def configure_single_tool(tool: str, state: dict) -> dict:
405412
def _configure_one(tool: str, state: dict, provider: str | None) -> dict:
406413
"""Write one tool's config, routing through ``provider`` when set."""
407414
if provider:
408-
provider_models, error = resolve_provider_models(tool, state, provider)
415+
provider_models, error, relayed = resolve_provider_models(tool, state, provider)
409416
if error:
410417
raise RuntimeError(error)
411-
return configure_tool(tool, state, None, provider=provider, provider_models=provider_models)
418+
return configure_tool(
419+
tool, state, None, provider=provider, provider_models=provider_models, relayed=relayed
420+
)
412421
if tool == "codex":
413422
return configure_tool("codex", state)
414423
state, model = resolve_launch_model(tool, state, None)
@@ -477,6 +486,10 @@ def validate_tool(tool: str) -> tuple[bool, str]:
477486
spec = TOOL_SPECS[tool]
478487
binary = spec["binary"]
479488
module = _MODULES[tool]
489+
# Some configs (e.g. claude relayed) can't be probed with a live message —
490+
# the proxy + subscription login only exist at launch. Trust the written config.
491+
if hasattr(module, "skip_validation") and module.skip_validation(load_state()):
492+
return True, ""
480493
cmd = module.validate_cmd(binary)
481494
env = None
482495
if hasattr(module, "validate_env"):

0 commit comments

Comments
 (0)