Skip to content

Commit 86491e2

Browse files
Install Databricks AI Tools as part of the configuration step (#230)
* Install Databricks AI Tools during ucode configure `ucode configure` now runs `databricks aitools install` for each coding agent it sets up (claude-code, codex, opencode, copilot), so agents work with Databricks out of the box. Best-effort: a failure only warns with the CLI's own error, since AI Tools aren't required to launch an agent. Co-authored-by: Isaac * Require Databricks CLI v1.0.0 for AI Tools; simplify install v1.0.0 is the release that ships `databricks aitools install --agents ... --scope`, so bump MIN_DATABRICKS_CLI_VERSION to (1, 0, 0) and drop the decorative AITOOLS_MIN_CLI_VERSION. `ensure_databricks_cli_version` already runs (and auto-upgrades) before every path that reaches install_ai_tools, so the install no longer guesses a version cause on failure: it just surfaces the CLI's own error. Co-authored-by: Isaac * Decode bytes stderr in AI Tools install warning subprocess.TimeoutExpired.stderr is bytes even when the call used text=True (a CPython quirk), so a timed-out `databricks aitools install` rendered its warning as `b'...'`. Decode bytes before building the reason string, and cover the timeout-with-stderr path with a test. Co-authored-by: Isaac * Make Databricks AI Tools install opt-out via configure Adds `configure --enable-databricks-ai-tools/--disable-databricks-ai-tools` (installed by default) plus an interactive prompt that defaults yes but honors a prior opt-out. The choice persists per workspace as `databricks_ai_tools_enabled` and gates install_ai_tools_for_agents. Interim client-side control ahead of admin-managed configuration. Co-authored-by: Isaac * Ask the AI Tools opt-out last in interactive configure Move the "Install Databricks AI Tools?" prompt out of the top-level configure body into configure_workspace_command, so it runs after the workspace and agent picks instead of before them. Reword it to explain it adds Databricks skills and plugins. An explicit --enable/--disable flag still answers upfront and skips the prompt. Co-authored-by: Isaac --------- Co-authored-by: Rohit Agrawal <10210921+rohita5l@users.noreply.github.com>
1 parent 3e34d62 commit 86491e2

8 files changed

Lines changed: 414 additions & 4 deletions

File tree

src/ucode/agents/__init__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from ucode.databricks import (
2121
BEDROCK_PROVIDER_TYPES,
2222
get_databricks_token,
23+
install_ai_tools,
2324
install_databricks_cli,
2425
map_bedrock_claude_models,
2526
resolve_provider_service,
@@ -65,6 +66,23 @@
6566
DEFAULT_TOOL = "codex"
6667
BUNDLE_VERSION = 1
6768

69+
# ucode tool -> `databricks aitools` agent id. gemini/pi aren't supported.
70+
AITOOLS_AGENT_TOKENS = {
71+
"claude": "claude-code",
72+
"codex": "codex",
73+
"opencode": "opencode",
74+
"copilot": "copilot",
75+
}
76+
77+
78+
def install_ai_tools_for_agents(tools: list[str], state: dict) -> None:
79+
"""Install Databricks AI Tools for the coding agents that support them
80+
(gemini/pi have no ``aitools`` support and are dropped)."""
81+
if state.get("databricks_ai_tools_enabled", True) is False:
82+
return
83+
agents = [AITOOLS_AGENT_TOKENS[tool] for tool in tools if tool in AITOOLS_AGENT_TOKENS]
84+
install_ai_tools(agents, state.get("profile"))
85+
6886

6987
def normalize_tool(tool: str) -> str:
7088
normalized = TOOL_ALIASES.get(tool.strip().lower())
@@ -380,6 +398,7 @@ def configure_single_tool(tool: str, state: dict) -> dict:
380398
available_tools = list(set((state.get("available_tools") or []) + [tool]))
381399
state["available_tools"] = available_tools
382400
save_state(state)
401+
install_ai_tools_for_agents([tool], state)
383402
return state
384403

385404

@@ -410,6 +429,7 @@ def configure_selected_tools(state: dict, tools: list[str]) -> dict:
410429
existing = state.get("available_tools") or []
411430
state["available_tools"] = sorted(set(existing) | set(tools))
412431
save_state(state)
432+
install_ai_tools_for_agents(tools, state)
413433
return state
414434

415435

src/ucode/cli.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
prompt_for_selection,
8181
prompt_for_tools,
8282
prompt_for_workspace,
83+
prompt_yes_no_default,
8384
set_verbosity,
8485
spinner,
8586
status_badge,
@@ -218,6 +219,7 @@ def configure_shared_state(
218219
skip_model_discovery: bool = False,
219220
skip_preflight: bool = False,
220221
fable_enabled: bool | None = None,
222+
databricks_ai_tools_enabled: bool | None = None,
221223
) -> dict:
222224
"""Log into Databricks, enforce AI Gateway v2, fetch model lists, persist state.
223225
@@ -249,6 +251,14 @@ def configure_shared_state(
249251
use_pat = bool(prior_state.get("use_pat")) and previous_workspace == workspace
250252
if fable_enabled is None:
251253
fable_enabled = bool(prior_state.get("fable_enabled")) and previous_workspace == workspace
254+
if databricks_ai_tools_enabled is None:
255+
# Opt-out: on by default. With no flag, keep this workspace's prior
256+
# choice but don't inherit another workspace's opt-out.
257+
disabled = (
258+
prior_state.get("databricks_ai_tools_enabled") is False
259+
and previous_workspace == workspace
260+
)
261+
databricks_ai_tools_enabled = not disabled
252262
fetch_all = tools is None
253263

254264
# Assemble the shared workspace state that doesn't depend on model discovery:
@@ -278,6 +288,7 @@ def configure_shared_state(
278288
state["fable_enabled"] = True
279289
else:
280290
state.pop("fable_enabled", None)
291+
state["databricks_ai_tools_enabled"] = databricks_ai_tools_enabled
281292
state["base_urls"] = build_shared_base_urls(workspace)
282293

283294
if skip_preflight:
@@ -436,6 +447,7 @@ def _configure_shared_workspace_states(
436447
force_login: bool,
437448
use_pat: bool = False,
438449
fable_enabled: bool | None = None,
450+
databricks_ai_tools_enabled: bool | None = None,
439451
) -> list[dict]:
440452
if not workspaces:
441453
raise RuntimeError("At least one workspace must be provided.")
@@ -449,6 +461,7 @@ def _configure_shared_workspace_states(
449461
force_login=force_login,
450462
use_pat=use_pat,
451463
fable_enabled=fable_enabled,
464+
databricks_ai_tools_enabled=databricks_ai_tools_enabled,
452465
)
453466
)
454467
return states
@@ -529,6 +542,7 @@ def configure_workspace_command(
529542
use_pat: bool = False,
530543
skip_validate: bool = False,
531544
fable_enabled: bool | None = None,
545+
databricks_ai_tools_enabled: bool | None = None,
532546
) -> int:
533547
if tool is not None and selected_tools is not None:
534548
raise RuntimeError("Use either --agent or --agents, not both.")
@@ -547,6 +561,7 @@ def configure_workspace_command(
547561
force_login=True,
548562
use_pat=use_pat,
549563
fable_enabled=fable_enabled,
564+
databricks_ai_tools_enabled=databricks_ai_tools_enabled,
550565
)
551566
state = states[0]
552567
state = configure_single_tool(tool, state)
@@ -584,6 +599,7 @@ def configure_workspace_command(
584599
force_login=True,
585600
use_pat=use_pat,
586601
fable_enabled=fable_enabled,
602+
databricks_ai_tools_enabled=databricks_ai_tools_enabled,
587603
)
588604
state = states[0]
589605
save_state(state)
@@ -632,6 +648,16 @@ def configure_workspace_command(
632648
for tool_name in picked:
633649
state = _maybe_select_provider_service(tool_name, state)
634650

651+
# Last question in the interactive flow: opt out of AI Tools. When a flag
652+
# already decided it, configure_shared_state persisted that; skip the prompt.
653+
# The default is the resolved prior choice, so Enter won't undo a past opt-out.
654+
if databricks_ai_tools_enabled is None and offer_provider:
655+
state["databricks_ai_tools_enabled"] = prompt_yes_no_default(
656+
"Install Databricks AI Tools for your coding agents? "
657+
"This adds Databricks skills and plugins.",
658+
default=state.get("databricks_ai_tools_enabled", True),
659+
)
660+
635661
state = configure_selected_tools(state, picked)
636662

637663
summary_lines = [f"[bold]Workspace:[/bold] [cyan]{state['workspace']}[/cyan]"]
@@ -1102,6 +1128,15 @@ def configure(
11021128
"it configures Claude Code directly since Fable is Claude-only.",
11031129
),
11041130
] = None,
1131+
enable_databricks_ai_tools: Annotated[
1132+
bool | None,
1133+
typer.Option(
1134+
"--enable-databricks-ai-tools/--disable-databricks-ai-tools",
1135+
help="Install Databricks AI Tools (skills + plugins that teach agents to use "
1136+
"Databricks) for the configured agents. Installed by default; pass "
1137+
"--disable-databricks-ai-tools to opt out.",
1138+
),
1139+
] = None,
11051140
tracing: Annotated[
11061141
bool,
11071142
typer.Option(
@@ -1167,6 +1202,8 @@ def configure(
11671202
# target claude instead of dropping into the interactive agent picker.
11681203
if enable_fable is not None and agent is None and agents is None:
11691204
agent = "claude"
1205+
if enable_databricks_ai_tools is not None:
1206+
skip_kwargs["databricks_ai_tools_enabled"] = enable_databricks_ai_tools
11701207
if agent is not None:
11711208
tool = normalize_tool(agent)
11721209
install_tool_binary(

src/ucode/databricks.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@
4949
"https://raw.githubusercontent.com/databricks/setup-cli/main/install.ps1"
5050
)
5151
AI_GATEWAY_V2_DOCS_URL = "https://docs.databricks.com/aws/en/ai-gateway/overview-beta"
52-
MIN_DATABRICKS_CLI_VERSION = (0, 298, 0)
52+
# v1.0.0 is the release that ships `databricks aitools`.
53+
MIN_DATABRICKS_CLI_VERSION = (1, 0, 0)
5354
TOKEN_REFRESH_INTERVAL_SECONDS = 1800
5455

5556

@@ -548,6 +549,41 @@ def install_databricks_cli() -> None:
548549
ensure_databricks_cli_version()
549550

550551

552+
def install_ai_tools(agent_tokens: list[str], profile: str | None = None) -> None:
553+
"""Install Databricks AI Tools for the given agents (e.g. ``claude-code``).
554+
555+
Databricks AI Tools is the set of skills and plugins that teach coding
556+
agents how to work with Databricks (installed via ``databricks aitools``).
557+
Idempotent and best-effort: any failure only warns (surfacing the CLI's
558+
own error), since AI Tools aren't required to launch an agent."""
559+
if not agent_tokens:
560+
return
561+
562+
agents_arg = ",".join(agent_tokens)
563+
try:
564+
with spinner(f"Installing Databricks AI Tools for {agents_arg}..."):
565+
run(
566+
["databricks", "aitools", "install", "--agents", agents_arg, "--scope", "global"]
567+
+ _profile_args(profile),
568+
check=True,
569+
capture_output=True,
570+
text=True,
571+
timeout=300,
572+
)
573+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as exc:
574+
# The CLI version is already guaranteed by ensure_databricks_cli_version,
575+
# so any failure here is something else (e.g. an agent binary missing
576+
# from PATH). Surface the CLI's own error rather than guessing a cause.
577+
detail = getattr(exc, "stderr", None) or ""
578+
if isinstance(detail, bytes): # TimeoutExpired.stderr is bytes even with text=True
579+
detail = detail.decode(errors="replace")
580+
detail = detail.strip()
581+
reason = detail.splitlines()[-1] if detail else str(exc)
582+
print_warning(f"Could not install Databricks AI Tools: {reason}")
583+
else:
584+
print_success("Databricks AI Tools installed")
585+
586+
551587
def _profile_args(profile: str | None) -> list[str]:
552588
"""Return ``["--profile", profile]`` when set, otherwise an empty list.
553589

src/ucode/ui.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,23 @@ def prompt_yes_no(prompt: str) -> bool:
302302
print_err("Please answer yes or no.")
303303

304304

305+
def prompt_yes_no_default(prompt: str, *, default: bool) -> bool:
306+
"""Empty answer or closed stdin (EOF) takes ``default`` (no abort on piped runs)."""
307+
hint = "(Y/n)" if default else "(y/N)"
308+
while True:
309+
try:
310+
response = console.input(f"{label(prompt)} {muted(hint)} {muted('›')} ").strip().lower()
311+
except EOFError:
312+
return default
313+
if not response:
314+
return default
315+
if response in {"y", "yes"}:
316+
return True
317+
if response in {"n", "no"}:
318+
return False
319+
print_err("Please answer yes or no.")
320+
321+
305322
def prompt_for_choice(prompt: str, options: list[tuple[str, str]]) -> str:
306323
console.print()
307324
for index, (_, option_label) in enumerate(options, start=1):

tests/test_agents_init.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
configure_selected_tools,
1515
default_model_for_tool,
1616
ensure_tool_binary_available,
17+
install_ai_tools_for_agents,
1718
install_tool_binary,
1819
normalize_tool,
1920
provider_permission_error,
@@ -60,6 +61,70 @@ def test_each_agent_exposes_update_check(self):
6061
assert callable(module.is_update_available), f"{tool} missing is_update_available"
6162

6263

64+
class TestInstallAiToolsForAgents:
65+
def _capture(self, monkeypatch):
66+
captured = {}
67+
monkeypatch.setattr(
68+
agents_mod,
69+
"install_ai_tools",
70+
lambda agents, profile: captured.update(agents=agents, profile=profile),
71+
)
72+
return captured
73+
74+
def test_maps_supported_tools_and_drops_others(self, monkeypatch):
75+
captured = self._capture(monkeypatch)
76+
# gemini and pi aren't supported by `databricks aitools`, so they drop.
77+
install_ai_tools_for_agents(["claude", "codex", "gemini", "pi"], {"profile": "prof"})
78+
assert captured == {"agents": ["claude-code", "codex"], "profile": "prof"}
79+
80+
def test_installed_by_default(self, monkeypatch):
81+
# Opt-out: absent flag means install.
82+
captured = self._capture(monkeypatch)
83+
install_ai_tools_for_agents(["claude"], {"profile": "p"})
84+
assert captured == {"agents": ["claude-code"], "profile": "p"}
85+
86+
def test_skipped_when_disabled(self, monkeypatch):
87+
# `configure --disable-databricks-ai-tools` persists this False.
88+
captured = self._capture(monkeypatch)
89+
install_ai_tools_for_agents(
90+
["claude"], {"profile": "p", "databricks_ai_tools_enabled": False}
91+
)
92+
assert captured == {} # install_ai_tools never called
93+
94+
95+
class TestConfigureWiresAiToolsInstall:
96+
"""Both configure chokepoints must trigger AI Tools install."""
97+
98+
def _stub_configure(self, monkeypatch):
99+
captured = {}
100+
monkeypatch.setattr(agents_mod, "configure_tool", lambda tool, state, model=None: state)
101+
monkeypatch.setattr(agents_mod, "save_state", lambda state: None)
102+
monkeypatch.setattr(
103+
agents_mod,
104+
"install_ai_tools",
105+
lambda agents, profile: captured.update(agents=agents, profile=profile),
106+
)
107+
return captured
108+
109+
def test_configure_single_tool_triggers_install(self, monkeypatch):
110+
captured = self._stub_configure(monkeypatch)
111+
agents_mod.configure_single_tool("codex", {"codex_models": ["m"], "profile": "myprof"})
112+
assert captured == {"agents": ["codex"], "profile": "myprof"}
113+
114+
def test_configure_selected_tools_triggers_install(self, monkeypatch):
115+
captured = self._stub_configure(monkeypatch)
116+
agents_mod.configure_selected_tools({"profile": "myprof"}, ["codex"])
117+
assert captured == {"agents": ["codex"], "profile": "myprof"}
118+
119+
def test_configure_single_tool_respects_disable(self, monkeypatch):
120+
captured = self._stub_configure(monkeypatch)
121+
agents_mod.configure_single_tool(
122+
"codex",
123+
{"codex_models": ["m"], "profile": "myprof", "databricks_ai_tools_enabled": False},
124+
)
125+
assert captured == {}
126+
127+
63128
class TestNormalizeTool:
64129
@pytest.mark.parametrize(
65130
"alias,expected",

0 commit comments

Comments
 (0)