Skip to content
Open
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
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand All @@ -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 <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

Expand Down
30 changes: 29 additions & 1 deletion src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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."""
Expand Down
124 changes: 123 additions & 1 deletion src/ucode/managed_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 (
Expand Down Expand Up @@ -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 <code> <reason>: <body>` 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 <json>`)."
)

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"]
Loading
Loading