diff --git a/README.md b/README.md index 70a343b..80b7a2d 100644 --- a/README.md +++ b/README.md @@ -194,8 +194,23 @@ ucode setup --dry-run ucode setup --from-file ./managed-settings.json ``` -Publishing replaces the workspace's config outright — there is no partial update yet, so anything -skipped in a re-run is dropped. +Once the manifest looks right, publish it: + +```bash +# Validate, show what would change, and ask before publishing. +ucode apply + +# Preview without publishing. +ucode apply --dry-run + +# Publish without the confirmation prompt (for CI). +ucode apply --yes +``` + +`apply` updates the workspace's existing config in place rather than replacing it, so a failed +publish leaves the current config intact. It is still a whole-manifest write: every field ucode +authors is sent, so anything skipped in a re-run is cleared rather than carried over. Developers +pick the new config up on their next ucode run. --- @@ -222,6 +237,8 @@ skipped in a re-run is dropped. | `ucode setup` | Author the workspace's managed coding config (workspace admins only) | | `ucode setup show` | Print the authored config and the payload `ucode apply` would publish | | `ucode setup --from-file ` | Load a hand-written managed config instead of running the prompts | +| `ucode apply` | Publish the authored managed config to the workspace (workspace admins only) | +| `ucode apply --yes` | Publish without the confirmation prompt | ## Managed Local Files diff --git a/src/ucode/cli.py b/src/ucode/cli.py index f05e70c..8b11efc 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -57,7 +57,7 @@ ) from ucode.managed_config import managed_agent_config_enabled, managed_launch_state from ucode.managed_resolve import managed_default_model, managed_provider_service -from ucode.managed_wizard import setup_command, show_command +from ucode.managed_wizard import apply_command, setup_command, show_command from ucode.mcp import ( MCP_CLIENTS, SKILLS_MCP_KIND, @@ -1967,6 +1967,34 @@ def setup_show_cmd() -> None: raise typer.Exit(code) +@app.command("apply") +def apply_cmd( + yes: Annotated[ + bool, + typer.Option("--yes", "-y", help="Publish without the confirmation prompt."), + ] = False, + dry_run: Annotated[ + bool, + typer.Option("--dry-run", help="Validate and preview without publishing."), + ] = False, +) -> None: + """Publish this workspace's managed coding config (workspace admins only).""" + set_dry_run(dry_run) + # See the `setup` callback: `typer.Exit` subclasses RuntimeError, so it must be raised after + # the try block or the handler below would report a successful exit as an error. + try: + install_databricks_cli() + code = apply_command(yes=yes) + except RuntimeError as exc: + print_err(str(exc)) + raise typer.Exit(1) from None + except KeyboardInterrupt: + print_err("Interrupted.") + raise typer.Exit(130) from None + if code: + raise typer.Exit(code) + + @app.command("status") def status_cmd() -> None: """Show current workspace, tool configs, and saved model selections.""" diff --git a/src/ucode/managed_wizard.py b/src/ucode/managed_wizard.py index 005c757..6bdf132 100644 --- a/src/ucode/managed_wizard.py +++ b/src/ucode/managed_wizard.py @@ -15,10 +15,13 @@ import json from pathlib import Path +from typing import cast from ucode.agents import TOOL_SPECS, check_gateway_endpoint +from ucode.config_io import is_dry_run from ucode.databricks import ( ANTHROPIC_FAMILIES, + create_coding_agent_config, discover_claude_models_unbucketed, ensure_databricks_auth, get_databricks_token, @@ -28,6 +31,7 @@ list_model_provider_services, list_workspace_budgets, service_usable_for_tool, + update_coding_agent_config, ) from ucode.managed_config import get_managed_config from ucode.managed_setup import ( @@ -881,4 +885,122 @@ def show_command() -> int: return 0 -__all__ = ["setup_command", "setup_from_file", "show_command"] +# Server-side failures an admin is actually likely to hit, mapped to something they can act on. The +# raw reasons are `HTTP : ` strings from the transport, and the body carries the +# API's `error_code`, so matching on that is more robust than on status codes alone. +def _explain_publish_failure(reason: str) -> str: + lowered = reason.lower() + if "feature_disabled" in lowered: + return ( + "Managed coding-agent configs aren't enabled on this workspace yet. Ask your Databricks " + "contact to enable the `codingAgentConfigCrudEnabled` flag for it, then re-run " + "`ucode apply`." + ) + if "permission_denied" in lowered or "http 403" in lowered: + return ( + "Publishing a managed config requires workspace admin. Your account can read the " + "workspace but not author its coding config." + ) + if "already_exists" in lowered: + return ( + "This workspace already has a managed config, but ucode couldn't read it to update in " + "place. Run `ucode apply` again — if it keeps failing, the existing config may need to " + "be deleted by hand." + ) + if "invalid_parameter_value" in lowered: + # The server names the offending field; passing it through beats paraphrasing. + return f"The workspace rejected the config: {reason}" + return f"Could not publish the managed config: {reason}" + + +def apply_command(*, yes: bool = False) -> int: + """Publish the authored manifest to the workspace. + + Updates the existing config in place when there is one, rather than deleting and recreating it: + a failed recreate would leave the workspace with no managed config at all, and every developer + would silently fall back to their own settings. Returns a process exit code. + """ + from ucode.cli import _prompt_for_configuration + + print_section("ucode apply") + + state = load_state() + workspace = state.get("workspace") + profile = state.get("profile") + if not workspace: + workspace, profile = _prompt_for_configuration() + + manifest = load_managed_settings(workspace) + if manifest is None: + raise RuntimeError( + "No managed config has been authored for this workspace. Run `ucode setup` first " + "(or `ucode setup --from-file `)." + ) + + errors = validate_manifest(manifest, state) + if errors: + print_err("The authored config is not valid, so it was not published:") + for error in errors: + print_note(error) + print_note("Re-run `ucode setup` to fix it, or edit ~/.ucode/managed-settings.json.") + return 1 + + ensure_databricks_auth(workspace, profile) + token = get_databricks_token(workspace, profile) + _require_admin(workspace, token) + + payload = serialize_managed_config(manifest) + _render_summary(workspace, manifest) + + # Read before writing: the resource name tells us whether to create or update, and shows the + # admin what they are about to overwrite. + with spinner("Checking for an existing managed config..."): + existing, reason = get_managed_config(workspace, token) + if reason is not None: + raise RuntimeError( + f"Could not check whether {workspace} already has a managed config: {reason}. " + "Refusing to publish without knowing, since that could overwrite a config silently." + ) + + existing_name = (existing or {}).get("name") + if existing is not None and not isinstance(existing_name, str): + raise RuntimeError( + "This workspace has a managed config but the API didn't return its resource name, so " + "ucode can't update it in place. Delete it in the workspace and re-run `ucode apply`." + ) + + console.print() + if existing is None: + print_note(f"This will create a new managed config on {workspace}.") + else: + agents = ", ".join((existing.get("enabled_agents") or {}).keys()) or "no agents" + print_warning( + f"This will replace the config already published on {workspace} (currently: {agents}). " + "Every developer picks the new one up on their next ucode run." + ) + if not yes and not prompt_yes_no_default("Publish this config?", default=False): + print_note("Nothing was published.") + return 1 + + if is_dry_run(): + print_success("Dry run: the config was validated but not published.") + return 0 + + if existing is None: + with spinner("Publishing the managed config..."): + published, publish_reason = create_coding_agent_config(workspace, token, payload) + else: + with spinner("Updating the managed config..."): + published, publish_reason = update_coding_agent_config( + workspace, token, cast("str", existing_name), payload + ) + if publish_reason is not None: + raise RuntimeError(_explain_publish_failure(publish_reason)) + + name = (published or {}).get("name") or existing_name or "coding-agent-configs/?" + print_success(f"Published {name} to {workspace}") + print_note("Developers pick this up on their next ucode run.") + return 0 + + +__all__ = ["apply_command", "setup_command", "setup_from_file", "show_command"] diff --git a/tests/test_managed_wizard.py b/tests/test_managed_wizard.py index d6b2f6b..76ed0df 100644 --- a/tests/test_managed_wizard.py +++ b/tests/test_managed_wizard.py @@ -14,6 +14,7 @@ import typer.main from typer.testing import CliRunner +import ucode.cli as cli_mod import ucode.config_io as config_io_mod import ucode.managed_setup as managed_setup_mod import ucode.managed_wizard as wizard @@ -1225,6 +1226,202 @@ def fake_sel(prompt, options, **kwargs): assert any("model" in p for p in searchable_prompts), searchable_prompts +class TestApplyCommand: + MANIFEST = { + "default_agent": "claude", + "enabled_agents": { + "claude": {"model_config": {"default_model": "system.ai.claude-opus-4-8"}} + }, + } + + @staticmethod + def _patches(**overrides): + """The network/auth boundary `apply_command` sits behind, with per-test overrides.""" + defaults = { + "load_state": lambda: {"workspace": WORKSPACE, "profile": "p", **STATE}, + "ensure_databricks_auth": lambda *a, **k: None, + "get_databricks_token": lambda *a, **k: "tok", + "is_workspace_admin": lambda *a, **k: True, + "get_managed_config": lambda *a, **k: (None, None), + "create_coding_agent_config": lambda *a, **k: ( + {"name": "coding-agent-configs/new"}, + None, + ), + "update_coding_agent_config": lambda *a, **k: ( + {"name": "coding-agent-configs/old"}, + None, + ), + "prompt_yes_no_default": lambda *a, **k: True, + } + defaults.update(overrides) + return [patch.object(wizard, name, value) for name, value in defaults.items()] + + def _run(self, *, yes=False, **overrides): + import contextlib + + with contextlib.ExitStack() as stack: + for p in self._patches(**overrides): + stack.enter_context(p) + return wizard.apply_command(yes=yes) + + def test_unauthored_config_is_an_actionable_error(self): + with patch.object(wizard, "load_state", return_value={"workspace": WORKSPACE}): + with pytest.raises(RuntimeError, match="ucode setup"): + wizard.apply_command() + + def test_creates_when_no_config_exists(self): + managed_setup_mod.save_managed_settings(WORKSPACE, self.MANIFEST) + created = {} + + def fake_create(workspace, token, payload): + created.update(workspace=workspace, payload=payload) + return {"name": "coding-agent-configs/new"}, None + + assert self._run(create_coding_agent_config=fake_create) == 0 + assert created["workspace"] == WORKSPACE + # What goes over the wire is proto-JSON, not ucode's manifest shape. + assert created["payload"]["default_agent"] == "CODING_AGENT_CLAUDE_CODE" + + def test_updates_in_place_when_a_config_exists(self): + # Delete-then-create would leave the workspace with no config if the create failed, so an + # existing config must be PATCHed rather than replaced. + managed_setup_mod.save_managed_settings(WORKSPACE, self.MANIFEST) + existing = {"name": "coding-agent-configs/abc", "enabled_agents": {"codex": {}}} + updated = {} + created = {"called": False} + + def fake_update(workspace, token, name, payload): + updated.update(name=name, payload=payload) + return {"name": name}, None + + def fake_create(*a, **k): + created["called"] = True + return {}, None + + assert ( + self._run( + get_managed_config=lambda *a, **k: (existing, None), + update_coding_agent_config=fake_update, + create_coding_agent_config=fake_create, + ) + == 0 + ) + assert updated["name"] == "coding-agent-configs/abc" + assert created["called"] is False + + def test_invalid_manifest_is_not_published(self): + # `default_agent` names an agent that isn't enabled. + managed_setup_mod.save_managed_settings( + WORKSPACE, {"default_agent": "codex", "enabled_agents": {"claude": {}}} + ) + created = {"called": False} + + def fake_create(*a, **k): + created["called"] = True + return {}, None + + assert self._run(create_coding_agent_config=fake_create) == 1 + assert created["called"] is False + + def test_declining_the_prompt_publishes_nothing(self): + managed_setup_mod.save_managed_settings(WORKSPACE, self.MANIFEST) + created = {"called": False} + + def fake_create(*a, **k): + created["called"] = True + return {}, None + + code = self._run( + prompt_yes_no_default=lambda *a, **k: False, create_coding_agent_config=fake_create + ) + assert code == 1 + assert created["called"] is False + + def test_yes_skips_the_prompt(self): + managed_setup_mod.save_managed_settings(WORKSPACE, self.MANIFEST) + + def refuse(*a, **k): + raise AssertionError("--yes must not prompt") + + assert self._run(yes=True, prompt_yes_no_default=refuse) == 0 + + def test_non_admin_is_rejected_before_publishing(self): + managed_setup_mod.save_managed_settings(WORKSPACE, self.MANIFEST) + created = {"called": False} + + def fake_create(*a, **k): + created["called"] = True + return {}, None + + with pytest.raises(RuntimeError, match="not an admin"): + self._run( + is_workspace_admin=lambda *a, **k: False, create_coding_agent_config=fake_create + ) + assert created["called"] is False + + def test_unreadable_existing_config_refuses_to_publish(self): + # Publishing without knowing whether a config exists risks silently overwriting one. + managed_setup_mod.save_managed_settings(WORKSPACE, self.MANIFEST) + created = {"called": False} + + def fake_create(*a, **k): + created["called"] = True + return {}, None + + with pytest.raises(RuntimeError, match="Refusing to publish"): + self._run( + get_managed_config=lambda *a, **k: (None, "HTTP 500 Server Error"), + create_coding_agent_config=fake_create, + ) + assert created["called"] is False + + def test_existing_config_without_a_resource_name_is_an_error(self): + managed_setup_mod.save_managed_settings(WORKSPACE, self.MANIFEST) + with pytest.raises(RuntimeError, match="resource name"): + self._run(get_managed_config=lambda *a, **k: ({"enabled_agents": {}}, None)) + + def test_dry_run_validates_without_publishing(self, monkeypatch): + managed_setup_mod.save_managed_settings(WORKSPACE, self.MANIFEST) + monkeypatch.setattr(config_io_mod, "_dry_run", True) + created = {"called": False} + + def fake_create(*a, **k): + created["called"] = True + return {}, None + + assert self._run(create_coding_agent_config=fake_create) == 0 + assert created["called"] is False + + +class TestPublishFailureMessages: + """The server's error codes, turned into something an admin can act on.""" + + def test_feature_disabled_names_the_flag(self): + message = wizard._explain_publish_failure( + 'HTTP 400 Bad Request: {"error_code":"FEATURE_DISABLED","message":"..."}' + ) + assert "codingAgentConfigCrudEnabled" in message + + def test_permission_denied_says_admin_is_required(self): + message = wizard._explain_publish_failure( + 'HTTP 403 Forbidden: {"error_code":"PERMISSION_DENIED"}' + ) + assert "workspace admin" in message + + def test_invalid_parameter_value_is_passed_through_verbatim(self): + # The server names the offending field, which is more useful than any paraphrase. + reason = ( + 'HTTP 400 Bad Request: {"error_code":"INVALID_PARAMETER_VALUE",' + '"message":"budget_policy.tiers[0].spending_percentage must be between 0 and 1"}' + ) + message = wizard._explain_publish_failure(reason) + assert "budget_policy.tiers[0].spending_percentage" in message + + def test_unknown_failure_still_surfaces_the_reason(self): + message = wizard._explain_publish_failure("network error: timed out") + assert "timed out" in message + + class TestCliWiring: def test_setup_is_registered(self): result = runner.invoke(app, ["--help"]) @@ -1246,6 +1443,31 @@ def test_setup_show_is_registered(self): assert result.exit_code == 0 assert "show" in result.output + def test_apply_is_registered(self): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "apply" in result.output + + def test_apply_declares_yes_and_dry_run(self): + # Asserted on the declared options rather than rendered help, which Rich ellipsizes at + # narrow terminal widths (see test_setup_help_lists_from_file). + command = typer.main.get_command(app).commands["apply"] # type: ignore[attr-defined] + declared = {opt for param in command.params for opt in param.opts} + assert {"--yes", "--dry-run"} <= declared + + def test_apply_error_exits_nonzero_with_a_message(self): + with patch.object(cli_mod, "apply_command", side_effect=RuntimeError("no config authored")): + result = runner.invoke(app, ["apply"]) + assert result.exit_code == 1 + + def test_successful_apply_exits_zero(self): + # Same trap as `setup`: `typer.Exit` subclasses RuntimeError, so raising it inside the + # command's try block would report success as "ERROR 0". + with patch.object(cli_mod, "apply_command", return_value=0): + result = runner.invoke(app, ["apply"]) + assert result.exit_code == 0 + assert "ERROR" not in result.output + def test_successful_setup_exits_zero(self): # `typer.Exit` subclasses RuntimeError, so a success code must not be caught and reported # as an error by the command's own RuntimeError handler.