Skip to content
Closed
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
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,10 @@ ug configure --profiles DEFAULT --agents claude,codex

Auth behaves the same as `--workspaces`: an OAuth `databricks auth login` is forced by default.

For CI or headless environments where the profile holds a personal access token (`auth_type = pat` in `~/.databrickscfg`), add `--use-pat`. It must be combined with `--profiles` — ug never picks up a PAT implicitly — and runs no interactive login: the profile's token is used for the whole setup (and by launched agents afterwards), with workspace access verified against the AI Gateway. `--skip-validate` additionally skips the post-configure test message sent through each agent, so configure only writes config files with the freshly discovered models. Together these make setup fully non-interactive:
For CI or headless environments where the profile holds a personal access token (`auth_type = pat` in `~/.databrickscfg`), add `--use-pat`. It must be combined with `--profiles` — ug never picks up a PAT implicitly — and runs no interactive login: the profile's token is used for the whole setup (and by launched agents afterwards), with workspace access verified against the AI Gateway. This makes setup fully non-interactive:

```bash
ug configure --profiles DEFAULT --agents claude,codex --use-pat --skip-validate --skip-upgrade
ug configure --profiles DEFAULT --agents claude,codex --use-pat --skip-upgrade
```

### MCP servers (optional)
Expand Down Expand Up @@ -327,7 +327,6 @@ The output looks like:
| `ug codex --refresh` | Re-check Databricks, refresh models/configuration, and launch Codex |
| `ug claude --enable-smart-routing` | Enable AI Gateway routing for Claude Code sessions and subagents |
| `ug claude --refresh` | Re-check Databricks, refresh models/configuration, and launch Claude Code |
| `ug configure --skip-validate` | Write configs without sending a test message through each agent |
| `ug configure --agents claude,codex,pi --skip-unavailable` | Configure the requested agents that are available; skip the rest with a warning |
| `ug configure --agents claude --mcp system.ai.slack` | Configure an agent and register its Databricks MCP server(s) in one command |
| `ug mcp add --location system.ai` | Register a schema's MCP servers, keeping any already configured (additive; never removes) |
Expand Down Expand Up @@ -365,7 +364,7 @@ control the installation.
| `~/.copilot/.env` | GitHub Copilot CLI |
| `~/.pi/agent/models.json` | Pi |
| `~/.cursor/mcp.json` | Cursor Agent (MCP servers only) |
| `~/.ucode/managed-state.json` | The managed config (published by an admin through the AI Gateway) refreshed from the workspace on launch |
| `~/.ucode/managed-config.json` | The managed config (published by an admin through the AI Gateway) refreshed from the workspace on launch |
| `~/.ucode/managed-backups/` | Baseline backups for OS-managed files changed by ug |

Existing files are backed up before being overwritten. `ug revert` restores backups.
Expand Down
187 changes: 92 additions & 95 deletions src/ucode/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,19 @@
resolve_provider_service,
)
from ucode.managed_files import managed_write_batch
from ucode.state import get_provider_service, load_state, save_state
from ucode.managed_resolve import (
managed_provider_service,
managed_supplies_models,
resolve_state,
)
from ucode.state import (
_without_managed_overlay,
get_provider_service,
load_state,
save_state,
)
from ucode.telemetry import agent_version
from ucode.ui import (
console,
is_low_verbosity,
print_err,
print_note,
print_section,
Expand Down Expand Up @@ -472,24 +480,35 @@ def launch(
_MODULES[tool].launch(state, tool_args, options=options)


def check_gateway_endpoint(state: dict, tool: str) -> bool:
"""V2-only: a tool is available iff we discovered models for it."""
def check_gateway_endpoint(state: dict, tool: str, managed: dict | None = None) -> bool:
"""V2-only: a tool is available iff we discovered models for it or the managed config supplies them.

An agent whose models come only from the managed config (no discovered models) must count as
available, so check both discovered models and managed-supplied models.
"""
if tool == "claude":
return bool(state.get("claude_models"))
if tool == "opencode":
return bool(state.get("opencode_models"))
if tool == "codex":
return bool(state.get("codex_models"))
if tool == "gemini":
return bool(state.get("gemini_models"))
if tool == "copilot":
return bool(state.get("claude_models")) or bool(state.get("codex_models"))
if tool == "pi":
return (
discovered = bool(state.get("claude_models"))
elif tool == "opencode":
discovered = bool(state.get("opencode_models"))
elif tool == "codex":
discovered = bool(state.get("codex_models"))
elif tool == "gemini":
discovered = bool(state.get("gemini_models"))
elif tool == "copilot":
discovered = bool(state.get("claude_models")) or bool(state.get("codex_models"))
elif tool == "pi":
discovered = (
bool(state.get("claude_models"))
or bool(state.get("codex_models"))
or bool(state.get("gemini_models"))
)
else:
return False

if discovered:
return True
if managed and managed_supplies_models(managed, tool):
return True
return False


Expand All @@ -514,14 +533,29 @@ def _availability_failure_detail(tool: str, state: dict) -> str:
return " (" + "; ".join(parts) + ")"


def configure_single_tool(tool: str, state: dict) -> dict:
"""Check availability, configure, and persist state for one tool only."""
def configure_single_tool(tool: str, state: dict, managed: dict | None = None) -> dict:
"""Check availability, configure, and persist state for one tool only.

If managed config is provided, it is applied to the state (its settings take
precedence) and the provider precedence rules are enforced.
"""
# Apply managed config before resolving provider, so admin settings win
if managed is not None:
state = resolve_state(managed, state, tool)

provider = get_provider_service(state, tool)

# When the managed config names its own model source without a provider, clear any persisted
# provider so the managed source drives the picker/catalog.
if managed is not None and managed_supplies_models(managed, tool):
if not managed_provider_service(managed, tool):
provider = None

# A Model Provider Service routes through the same gateway and pins no
# Databricks model, so the per-tool model availability check doesn't apply.
if not provider:
with spinner(f"Checking {TOOL_SPECS[tool]['display']} availability..."):
ok = check_gateway_endpoint(state, tool)
ok = check_gateway_endpoint(state, tool, managed=managed)
if not ok:
detail = _availability_failure_detail(tool, state)
raise RuntimeError(
Expand Down Expand Up @@ -559,19 +593,43 @@ def _configure_one(tool: str, state: dict, provider: str | None) -> dict:


def configure_selected_tools(
state: dict, tools: list[str], *, install_ai_tools: bool = True
state: dict, tools: list[str], *, install_ai_tools: bool = True, managed: dict | None = None
) -> dict:
"""Configure the given tools. Caller is responsible for ensuring each tool
is available on the workspace.

Merges newly-configured tools into state['available_tools'] rather than
replacing it, so a previously-configured tool the user didn't pick this
run is preserved.
run is preserved. If managed config is provided, it is applied to each tool
(its settings take precedence) and the provider precedence rules are enforced.
"""
# Resolve each tool from an overlay-free state so tools' managed overlays stay independent
# and never leak into persisted state.
developer_state = state
with managed_write_batch(_managed_settings_displays(tools)):
for tool in tools:
state = _configure_one(tool, state, get_provider_service(state, tool))

# Apply managed config before resolving the provider so admin settings win.
tool_state = developer_state
if managed is not None:
tool_state = resolve_state(managed, developer_state, tool)

provider = get_provider_service(tool_state, tool)

# When the managed config names its own model source without a provider, clear any
# persisted provider so the managed source drives the picker/catalog.
if managed is not None and managed_supplies_models(managed, tool):
if not managed_provider_service(managed, tool):
provider = None

tool_configured = _configure_one(tool, tool_state, provider)
# Persist the developer's own state, not the managed overlay (its values already
# reached the file via resolution above); keep non-overlay changes like auth.
if managed is not None:
developer_state = _without_managed_overlay(tool_configured)
else:
developer_state = tool_configured

state = developer_state
existing = state.get("available_tools") or []
state["available_tools"] = sorted(set(existing) | set(tools))
save_state(state)
Expand Down Expand Up @@ -623,20 +681,27 @@ def ensure_provider_state(tool: str) -> dict:
return state


def validate_tool(tool: str) -> tuple[bool, str]:
"""Invoke a tool with a simple prompt to verify it works. Returns (ok, error_msg)."""
def validate_tool(tool: str, state: dict | None = None) -> tuple[bool, str]:
"""Invoke a tool with a simple prompt to verify it works. Returns (ok, error_msg).

When ``state`` is provided (post-configure validation), use it as-is since it holds the
managed-resolved values that were written to the agent config. When ``state`` is None
(other paths), load the persisted state.
"""
if state is None:
state = load_state()
spec = TOOL_SPECS[tool]
binary = spec["binary"]
module = _MODULES[tool]
# Some configs (e.g. claude relayed) can't be probed with a live message —
# the proxy + subscription login only exist at launch. Trust the written config.
if hasattr(module, "skip_validation") and module.skip_validation(load_state()):
if hasattr(module, "skip_validation") and module.skip_validation(state):
return True, ""
cmd = module.validate_cmd(binary)
env = None
if hasattr(module, "validate_env"):
try:
env = module.validate_env(load_state())
env = module.validate_env(state)
except RuntimeError:
env = None
try:
Expand Down Expand Up @@ -668,71 +733,3 @@ def validate_tool(tool: str) -> tuple[bool, str]:
return False, str(exc)
except subprocess.TimeoutExpired:
return False, "timed out"


def provider_permission_error(tool: str, state: dict, err: str) -> str:
"""Rewrite the opaque gateway connection-permission failure into an
actionable message naming the Model Provider Service the user must be
granted access to. Returns ``err`` unchanged when it doesn't apply.
"""
provider = get_provider_service(state, tool)
if provider and "USE CONNECTION on SCHEMA_CONNECTION" in err:
return (
f"You don't have EXECUTE permission on the model provider service "
f"'{provider}'. Ask its owner to grant you access, then re-run "
f"`ucode configure`."
)
return err


def validate_all_tools(state: dict) -> None:
from rich.panel import Panel # local to avoid bumping module-level deps

from ucode.agents.pi import PI_SETTINGS_BACKUP_PATH, PI_SETTINGS_PATH
from ucode.config_io import restore_file

low_verbosity = is_low_verbosity()
console.print()
if low_verbosity:
console.print("[bold blue]Validating...[/bold blue]")
else:
console.print(
Panel(
"Testing each tool with a quick message...",
title="Validating",
style="bold blue",
expand=False,
)
)
results: list[tuple[str, bool]] = []
available_tools = list(state.get("available_tools") or [])
for tool, spec in TOOL_SPECS.items():
if tool not in available_tools:
continue
with spinner(f"Validating {spec['display']}..."):
ok, err = validate_tool(tool)
results.append((tool, ok))
if ok:
print_success(f"{spec['display']} is working")
else:
print_err(f"{spec['display']}: {provider_permission_error(tool, state, err)}")
managed = bool(state.get("managed_configs", {}).get(tool))
restore_file(spec["config_path"], spec["backup_path"], managed)
# Rollback settings.json for Pi
if tool == "pi":
restore_file(PI_SETTINGS_PATH, PI_SETTINGS_BACKUP_PATH, managed)
available_tools.remove(tool)
state["available_tools"] = available_tools
save_state(state)

success_tools = [(t, s) for t, s in results if s]
if success_tools and not low_verbosity:
console.print()
lines = []
for tool, _ in success_tools:
spec = TOOL_SPECS[tool]
lines.append(
f"[green]✓[/green] [bold]{spec['display']}[/bold] — "
f"run with [cyan]ucode {tool}[/cyan]"
)
console.print(Panel("\n".join(lines), title="Ready", style="green", expand=False))
Loading
Loading