diff --git a/src/ucode/agents/__init__.py b/src/ucode/agents/__init__.py index 3afd4fea..06e85073 100644 --- a/src/ucode/agents/__init__.py +++ b/src/ucode/agents/__init__.py @@ -253,8 +253,9 @@ def ensure_bootstrap_dependencies( *, update_existing: bool = False, prompt_optional_updates: bool = True, + skip_cli_version_check: bool = False, ) -> None: - install_databricks_cli() + install_databricks_cli(skip_version_check=skip_cli_version_check) install_tool_binary( tool, strict=True, diff --git a/src/ucode/cli.py b/src/ucode/cli.py index a4653389..6363f91d 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -1384,7 +1384,11 @@ def _launch_tool( needs_auto_configure = not existing.get("workspace") or tool not in ( existing.get("available_tools") or [] ) - ensure_bootstrap_dependencies(tool, update_existing=needs_auto_configure) + ensure_bootstrap_dependencies( + tool, + update_existing=needs_auto_configure, + skip_cli_version_check=skip_preflight, + ) if needs_auto_configure: _auto_configure_tool(tool) state = ensure_provider_state(tool) @@ -1574,15 +1578,17 @@ def _launch_tool( # Launch-only escape hatch for managed/headless launchers (e.g. omnigent) that # have already run `ucode configure`: skip the ~5-10s per-launch auth + AI -# Gateway re-validation. Distinct from the configure-only `--skip-validate`, -# which skips the model smoke test. +# Gateway re-validation, plus the Databricks CLI minimum-version check (whose +# `databricks aitools` floor otherwise false-positives on a usable public-preview +# build). Distinct from the configure-only `--skip-validate`, which skips the +# model smoke test. SkipPreflightOption = Annotated[ bool, typer.Option( "--skip-preflight", - help="Skip the per-launch Databricks auth + AI Gateway re-validation, trusting a " - "prior `ucode configure`. Launches with your own local settings, ignoring any " - "workspace managed config.", + help="Skip the per-launch Databricks auth + AI Gateway re-validation (and the " + "Databricks CLI minimum-version check), trusting a prior `ucode configure`. " + "Launches with your own local settings, ignoring any workspace managed config.", ), ] @@ -1656,7 +1662,7 @@ def _launch_managed_default( return if workspace: set_current_workspace(normalize_workspace_url(workspace)) - install_databricks_cli() + install_databricks_cli(skip_version_check=skip_preflight) state = load_state() current = state.get("workspace") if not current: diff --git a/src/ucode/databricks.py b/src/ucode/databricks.py index e8738318..7e92104e 100644 --- a/src/ucode/databricks.py +++ b/src/ucode/databricks.py @@ -649,9 +649,17 @@ def ensure_databricks_cli_version() -> None: ensure_databricks_cli_version() -def install_databricks_cli() -> None: +def install_databricks_cli(*, skip_version_check: bool = False) -> None: + """Ensure the Databricks CLI is installed and (unless skipped) new enough. + + ``skip_version_check`` is set on ``--skip-preflight`` launches: they trust a + prior ``ucode configure`` and must not re-run the minimum-version gate, whose + ``databricks aitools`` floor (v1.0.0) rejects a perfectly usable public-preview + build (e.g. v0.299.2) as a false positive. A missing CLI is still installed — + only the version *check* is bypassed.""" if shutil.which("databricks"): - ensure_databricks_cli_version() + if not skip_version_check: + ensure_databricks_cli_version() return print_section("Bootstrap") @@ -662,7 +670,8 @@ def install_databricks_cli() -> None: raise RuntimeError( "Databricks CLI install completed, but `databricks` is still not on PATH." ) - ensure_databricks_cli_version() + if not skip_version_check: + ensure_databricks_cli_version() def install_ai_tools(agent_tokens: list[str], profile: str | None = None) -> None: diff --git a/tests/test_cli.py b/tests/test_cli.py index c6d36080..d0759c35 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -739,7 +739,9 @@ def test_triggers_when_no_workspace(self): ): result = runner.invoke(app, ["claude"]) assert result.exit_code == 0, result.output - mock_bootstrap.assert_called_once_with("claude", update_existing=True) + mock_bootstrap.assert_called_once_with( + "claude", update_existing=True, skip_cli_version_check=False + ) mock_auto.assert_called_once_with("claude") def test_triggers_when_tool_not_in_available_tools(self): @@ -764,7 +766,9 @@ def test_triggers_when_tool_not_in_available_tools(self): ): result = runner.invoke(app, ["claude"]) assert result.exit_code == 0, result.output - mock_bootstrap.assert_called_once_with("claude", update_existing=True) + mock_bootstrap.assert_called_once_with( + "claude", update_existing=True, skip_cli_version_check=False + ) mock_auto.assert_called_once_with("claude") def test_skipped_when_already_configured(self): @@ -787,9 +791,34 @@ def test_skipped_when_already_configured(self): patch("ucode.cli.launch_agent"), ): runner.invoke(app, ["claude"]) - mock_bootstrap.assert_called_once_with("claude", update_existing=False) + mock_bootstrap.assert_called_once_with( + "claude", update_existing=False, skip_cli_version_check=False + ) mock_auto.assert_not_called() + def test_skip_preflight_bypasses_cli_version_check(self): + """`--skip-preflight` tells bootstrap to skip the CLI minimum-version gate, + so a public-preview `databricks` (e.g. v0.299.2) isn't a false positive.""" + with ( + patch("ucode.cli.ensure_bootstrap_dependencies") as mock_bootstrap, + patch("ucode.cli.load_state", return_value=MINIMAL_STATE), + patch("ucode.cli._auto_configure_tool"), + patch("ucode.cli.configure_shared_state", return_value=MINIMAL_STATE), + patch("ucode.cli.ensure_provider_state", return_value=MINIMAL_STATE), + patch( + "ucode.cli.resolve_launch_model", + return_value=(MINIMAL_STATE, "databricks-claude-sonnet-4"), + ), + patch("ucode.cli.configure_tool", return_value=MINIMAL_STATE), + patch("ucode.cli._fetch_managed_config", return_value=None), + patch("ucode.cli.launch_agent"), + ): + result = runner.invoke(app, ["claude", "--skip-preflight"]) + assert result.exit_code == 0, result.output + mock_bootstrap.assert_called_once_with( + "claude", update_existing=False, skip_cli_version_check=True + ) + class TestPassthroughArgs: @pytest.mark.parametrize( diff --git a/tests/test_databricks.py b/tests/test_databricks.py index bfdf3e36..2f468e53 100644 --- a/tests/test_databricks.py +++ b/tests/test_databricks.py @@ -28,6 +28,7 @@ ensure_pat_bearer, get_databricks_token, install_ai_tools, + install_databricks_cli, list_databricks_apps, list_databricks_connections, list_genie_spaces, @@ -1851,6 +1852,43 @@ def test_raises_when_version_unparseable(self, tmp_path, monkeypatch): ensure_databricks_cli_version() +class TestInstallDatabricksCli: + def test_checks_version_when_present(self, monkeypatch): + monkeypatch.setattr(db_mod.shutil, "which", lambda cmd: "/usr/bin/databricks") + checked = [] + monkeypatch.setattr(db_mod, "ensure_databricks_cli_version", lambda: checked.append(True)) + install_databricks_cli() + assert checked == [True] + + def test_skip_version_check_bypasses_version_gate(self, monkeypatch): + """`--skip-preflight` sets skip_version_check: an already-installed CLI is + trusted without the minimum-version gate, so a public-preview build is no + longer a false positive.""" + monkeypatch.setattr(db_mod.shutil, "which", lambda cmd: "/usr/bin/databricks") + checked = [] + monkeypatch.setattr(db_mod, "ensure_databricks_cli_version", lambda: checked.append(True)) + install_databricks_cli(skip_version_check=True) + assert checked == [] + + def test_skip_version_check_still_installs_when_missing(self, monkeypatch): + """A missing CLI is installed even under skip_version_check — only the + version *check* is bypassed, not the install.""" + present = {"databricks": None} + monkeypatch.setattr(db_mod.shutil, "which", lambda cmd: present.get(cmd)) + installed = [] + + def fake_installer(brew_subcommand="install"): + present["databricks"] = "/usr/bin/databricks" + installed.append(brew_subcommand) + + monkeypatch.setattr(db_mod, "_run_databricks_cli_installer", fake_installer) + checked = [] + monkeypatch.setattr(db_mod, "ensure_databricks_cli_version", lambda: checked.append(True)) + install_databricks_cli(skip_version_check=True) + assert installed == ["install"] + assert checked == [] + + class TestRunDatabricksCliInstaller: @pytest.mark.parametrize("brew_subcommand", ["install", "upgrade"]) def test_macos_uses_fully_qualified_tap_formula(self, monkeypatch, brew_subcommand):