diff --git a/README.md b/README.md index 77f9b33..4aa058a 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,8 @@ Discovered external MCP connections are listed directly. MCP auth uses a Databri | `ucode configure --dry-run` | Preview config files without writing them | | `ucode configure --agents claude,codex` | Configure specific agents without the interactive picker | | `ucode configure --workspaces https://first.databricks.com,https://second.databricks.com` | Configure workspaces without the interactive picker | +| `ucode configure --skip-install` | Configure workspace auth + Gateway routing only; don't install or update the agent CLIs (for callers that manage the binaries themselves) | +| `ucode configure --skip-validation` | Skip the post-configure check that runs each agent with a test message | ## Managed Local Files diff --git a/src/ucode/cli.py b/src/ucode/cli.py index f32bddf..4970abe 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -252,6 +252,9 @@ def configure_workspace_command( tool: str | None = None, selected_tools: list[str] | None = None, workspaces: list[tuple[str, str | None]] | None = None, + *, + skip_install: bool = False, + skip_validation: bool = False, ) -> int: if tool is not None and selected_tools is not None: raise RuntimeError("Use either --agent or --agents, not both.") @@ -272,6 +275,8 @@ def configure_workspace_command( expand=False, ) ) + if skip_validation: + return 0 with spinner(f"Validating {spec['display']}..."): ok, err = validate_tool(tool) if ok: @@ -320,8 +325,9 @@ def configure_workspace_command( print_note("No coding agents selected — nothing to configure.") return 0 - for tool_name in picked: - install_tool_binary(tool_name, strict=False, update_existing=True) + if not skip_install: + for tool_name in picked: + install_tool_binary(tool_name, strict=False, update_existing=True) state = configure_selected_tools(state, picked) @@ -338,10 +344,11 @@ def configure_workspace_command( ) ) - # Limit validation to just-configured tools so we don't re-validate - # previously-configured tools the user didn't touch this run. - validate_state = {**state, "available_tools": picked} - validate_all_tools(validate_state) + if not skip_validation: + # Limit validation to just-configured tools so we don't re-validate + # previously-configured tools the user didn't touch this run. + validate_state = {**state, "available_tools": picked} + validate_all_tools(validate_state) return 0 @@ -618,6 +625,24 @@ def configure( help="Also enable MLflow tracing for the configured workspace(s).", ), ] = False, + skip_install: Annotated[ + bool, + typer.Option( + "--skip-install", + help=( + "Don't install or update the agent CLIs; only configure workspace " + "auth + Gateway routing. Use when the agent binaries are managed " + "elsewhere (e.g. by a tool that embeds ucode)." + ), + ), + ] = False, + skip_validation: Annotated[ + bool, + typer.Option( + "--skip-validation", + help="Skip the post-configure check that runs each agent with a test message.", + ), + ] = False, ) -> None: """Configure workspace URL and AI Gateway.""" if ctx.invoked_subcommand is not None: @@ -630,26 +655,47 @@ def configure( workspace_entries = _parse_workspaces_option(workspaces) if workspaces is not None else None if agent is not None: tool = normalize_tool(agent) - install_tool_binary(tool, strict=True, update_existing=True) + if not skip_install: + install_tool_binary(tool, strict=True, update_existing=True) if workspace_entries is None: - configure_workspace_command(tool) + configure_workspace_command( + tool, skip_install=skip_install, skip_validation=skip_validation + ) else: - configure_workspace_command(tool, workspaces=workspace_entries) + configure_workspace_command( + tool, + workspaces=workspace_entries, + skip_install=skip_install, + skip_validation=skip_validation, + ) elif agents is not None: selected_tools = _parse_agents_option(agents) if workspace_entries is None: - configure_workspace_command(selected_tools=selected_tools) + configure_workspace_command( + selected_tools=selected_tools, + skip_install=skip_install, + skip_validation=skip_validation, + ) else: configure_workspace_command( - selected_tools=selected_tools, workspaces=workspace_entries + selected_tools=selected_tools, + workspaces=workspace_entries, + skip_install=skip_install, + skip_validation=skip_validation, ) else: # Tool binaries are installed after the user picks which agents # they want, in configure_workspace_command. if workspace_entries is None: - configure_workspace_command() + configure_workspace_command( + skip_install=skip_install, skip_validation=skip_validation + ) else: - configure_workspace_command(workspaces=workspace_entries) + configure_workspace_command( + workspaces=workspace_entries, + skip_install=skip_install, + skip_validation=skip_validation, + ) if tracing: # The workspaces were just configured, so enable tracing for them # directly instead of re-prompting. Fall back to the workspace that diff --git a/tests/test_cli.py b/tests/test_cli.py index e2c95e2..4b98897 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -380,7 +380,7 @@ def test_no_flag_calls_configure_all(self): ): result = runner.invoke(app, ["configure"]) assert result.exit_code == 0, result.output - mock_cfg.assert_called_once_with() + mock_cfg.assert_called_once_with(skip_install=False, skip_validation=False) def test_agents_flag_calls_configure_with_tools(self): with ( @@ -391,7 +391,9 @@ def test_agents_flag_calls_configure_with_tools(self): result = runner.invoke(app, ["configure", "--agents", "claude,codex"]) assert result.exit_code == 0, result.output mock_install.assert_not_called() - mock_cfg.assert_called_once_with(selected_tools=["claude", "codex"]) + mock_cfg.assert_called_once_with( + selected_tools=["claude", "codex"], skip_install=False, skip_validation=False + ) def test_agents_flag_normalizes_aliases_and_dedupes(self): with ( @@ -401,7 +403,9 @@ def test_agents_flag_normalizes_aliases_and_dedupes(self): ): result = runner.invoke(app, ["configure", "--agents", " claude-code, codex,claude "]) assert result.exit_code == 0, result.output - mock_cfg.assert_called_once_with(selected_tools=["claude", "codex"]) + mock_cfg.assert_called_once_with( + selected_tools=["claude", "codex"], skip_install=False, skip_validation=False + ) def test_workspaces_flag_calls_configure_with_workspaces(self): with ( @@ -422,7 +426,9 @@ def test_workspaces_flag_calls_configure_with_workspaces(self): workspaces=[ ("https://first.databricks.com", None), ("https://second.databricks.com", None), - ] + ], + skip_install=False, + skip_validation=False, ) def test_agents_and_workspaces_flags_call_configure_with_both(self): @@ -437,7 +443,10 @@ def test_agents_and_workspaces_flags_call_configure_with_both(self): ) assert result.exit_code == 0, result.output mock_cfg.assert_called_once_with( - selected_tools=["claude", "codex"], workspaces=[("https://first.com", None)] + selected_tools=["claude", "codex"], + workspaces=[("https://first.com", None)], + skip_install=False, + skip_validation=False, ) def test_agent_and_workspaces_flags_call_configure_with_both(self): @@ -452,7 +461,12 @@ def test_agent_and_workspaces_flags_call_configure_with_both(self): ) assert result.exit_code == 0, result.output mock_install.assert_called_once_with("claude", strict=True, update_existing=True) - mock_cfg.assert_called_once_with("claude", workspaces=[("https://first.com", None)]) + mock_cfg.assert_called_once_with( + "claude", + workspaces=[("https://first.com", None)], + skip_install=False, + skip_validation=False, + ) def test_agent_flag_calls_configure_with_tool(self): with ( @@ -463,7 +477,7 @@ def test_agent_flag_calls_configure_with_tool(self): result = runner.invoke(app, ["configure", "--agent", "claude"]) assert result.exit_code == 0, result.output mock_install.assert_called_once_with("claude", strict=True, update_existing=True) - mock_cfg.assert_called_once_with("claude") + mock_cfg.assert_called_once_with("claude", skip_install=False, skip_validation=False) def test_agent_flag_normalizes_alias(self): with ( @@ -473,7 +487,7 @@ def test_agent_flag_normalizes_alias(self): ): result = runner.invoke(app, ["configure", "--agent", "claude-code"]) assert result.exit_code == 0, result.output - mock_cfg.assert_called_once_with("claude") + mock_cfg.assert_called_once_with("claude", skip_install=False, skip_validation=False) def test_upgrade_runs_uv_tool_install(self): with patch("subprocess.run") as mock_run: @@ -704,3 +718,98 @@ def test_skips_purge_when_workspace_unchanged(self, monkeypatch): cli_mod.configure_shared_state("https://same.databricks.com") assert purge_calls == [] + + +class TestConfigureSkipFlags: + def test_skip_flags_forwarded_from_agents_invocation(self): + with ( + patch("ucode.cli.install_databricks_cli"), + patch("ucode.cli.install_tool_binary") as mock_install, + patch("ucode.cli.configure_workspace_command") as mock_cfg, + ): + result = runner.invoke( + app, + [ + "configure", + "--agents", + "codex", + "--workspaces", + "https://x.databricks.com", + "--skip-install", + "--skip-validation", + ], + ) + assert result.exit_code == 0, result.output + mock_install.assert_not_called() + mock_cfg.assert_called_once_with( + selected_tools=["codex"], + workspaces=[("https://x.databricks.com", None)], + skip_install=True, + skip_validation=True, + ) + + def test_skip_install_skips_eager_single_agent_install(self): + with ( + patch("ucode.cli.install_databricks_cli"), + patch("ucode.cli.install_tool_binary") as mock_install, + patch("ucode.cli.configure_workspace_command") as mock_cfg, + ): + result = runner.invoke(app, ["configure", "--agent", "codex", "--skip-install"]) + assert result.exit_code == 0, result.output + # The eager pre-configure install for --agent must be skipped... + mock_install.assert_not_called() + # ...and the flag forwarded so the inner command skips its install too. + mock_cfg.assert_called_once_with("codex", skip_install=True, skip_validation=False) + + def test_configure_help_lists_skip_flags(self): + result = runner.invoke(app, ["configure", "--help"]) + assert result.exit_code == 0 + output = _strip_ansi(result.output) + assert "--skip-install" in output + assert "--skip-validation" in output + + def test_skip_flags_avoid_install_and_validation(self): + from ucode.cli import configure_workspace_command + + state = {"workspace": "https://x.databricks.com", "available_tools": []} + with ( + patch("ucode.cli._configure_shared_workspace_states", return_value=[state]), + patch("ucode.cli.check_gateway_endpoint", return_value=True), + patch("ucode.cli.install_tool_binary") as mock_install, + patch("ucode.cli.configure_selected_tools", return_value=state) as mock_configure, + patch("ucode.cli.validate_all_tools") as mock_validate, + ): + rc = configure_workspace_command( + selected_tools=["codex"], + workspaces=[("https://x.databricks.com", None)], + skip_install=True, + skip_validation=True, + ) + assert rc == 0 + # The point of the flags: no agent binaries installed/updated and no + # test-message validation runs... + mock_install.assert_not_called() + mock_validate.assert_not_called() + # ...but workspace auth + Gateway routing config still happen. + mock_configure.assert_called_once_with(state, ["codex"]) + + def test_default_runs_install_and_validation(self): + from ucode.cli import configure_workspace_command + + state = {"workspace": "https://x.databricks.com", "available_tools": []} + with ( + patch("ucode.cli._configure_shared_workspace_states", return_value=[state]), + patch("ucode.cli.check_gateway_endpoint", return_value=True), + patch("ucode.cli.install_tool_binary") as mock_install, + patch("ucode.cli.configure_selected_tools", return_value=state), + patch("ucode.cli.validate_all_tools") as mock_validate, + ): + rc = configure_workspace_command( + selected_tools=["codex"], + workspaces=[("https://x.databricks.com", None)], + ) + assert rc == 0 + # Without the flags the existing behavior is unchanged — both the + # install and the validation run (proves the guards aren't always-skip). + mock_install.assert_called_once_with("codex", strict=False, update_existing=True) + mock_validate.assert_called_once()