Skip to content
Merged
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
13 changes: 3 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,21 +186,14 @@ skipped. It then offers tracing, managed MCP servers, skills, and a spend-based
switches the default agent and model as the workspace burns through a budget.

The result is written to `~/.ucode/managed-state.json` — the one local managed-config file — which
`ucode apply` publishes to the workspace. Because a launch reads that same file, `ucode --dry-run`
tries the authored config on this machine before it is published, without fetching or overwriting
it. Your own agent configs are left alone, with one exception: answering yes to tracing, MCP
servers, or skills runs the matching `ucode configure` step, which does configure this machine.
`ucode apply` publishes to the workspace. Your own agent configs are left alone, with one exception:
answering yes to tracing, MCP servers, or skills runs the matching `ucode configure` step, which
does configure this machine.

```bash
# Review the manifest and the exact payload `ucode apply` would publish.
ucode setup show

# Walk the flow without writing anything.
ucode setup --dry-run

# Try the authored config locally before publishing (no fetch, no overwrite).
ucode --dry-run

# Skip the prompts and load a hand-written config instead (validated before saving).
ucode setup --from-file ./managed-config.json
```
Expand Down
46 changes: 7 additions & 39 deletions src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
)
from ucode.agents.codex import revert_legacy_shared_config
from ucode.agents.pi import PI_SETTINGS_BACKUP_PATH, PI_SETTINGS_PATH
from ucode.config_io import is_dry_run, restore_file, set_dry_run
from ucode.config_io import restore_file, set_dry_run
Comment thread
tt-le marked this conversation as resolved.
from ucode.databricks import (
apply_pat_environment,
build_shared_base_urls,
Expand Down Expand Up @@ -1387,9 +1387,7 @@ def _fetch_budget_recommendation(
Enforcement is server-side, so a failed read only costs the recommendation: the config's own
``default_model`` still applies and the launch proceeds.
"""
# --dry-run resolves the agent from the last saved config alone, so it must not reach the
# control plane — mirroring the managed-config read, which is likewise skipped under --dry-run.
if managed is None or skip_preflight or is_dry_run():
if managed is None or skip_preflight:
return None
reason: str | None = None
recommendation = None
Expand Down Expand Up @@ -1735,8 +1733,8 @@ def _launch_tool(
# Register the managed config's MCP servers so they reach the agent's `/mcp` list. Nothing
# else on this path does it — the config only lists them — so without this a
# workspace-published server never shows up. Skipped under --skip-preflight (deliberately
# unmanaged) and --dry-run (writes nothing).
if managed is not None and not skip_preflight and not is_dry_run():
# unmanaged).
if managed is not None and not skip_preflight:
_register_managed_mcp_servers(managed, tool, state)
_apply_managed_skills(managed, tool, state)
print_success(f"Starting {TOOL_SPECS[tool]['display']}")
Expand Down Expand Up @@ -1788,14 +1786,6 @@ def default(
is_eager=True,
),
] = False,
dry_run: Annotated[
bool,
typer.Option(
"--dry-run",
help="Print config files without writing them. Uses the last saved managed "
"config instead of fetching a fresh one.",
),
] = False,
skip_preflight: SkipPreflightOption = False,
workspace: WorkspaceOption = None,
) -> None:
Expand All @@ -1805,11 +1795,8 @@ def default(
"""
if ctx.invoked_subcommand is not None:
return
set_dry_run(dry_run)
try:
_launch_managed_default(
ctx, dry_run=dry_run, skip_preflight=skip_preflight, workspace=workspace
)
_launch_managed_default(ctx, skip_preflight=skip_preflight, workspace=workspace)
except typer.Exit:
# `typer.Exit` subclasses RuntimeError, so it has to be re-raised ahead of the handler
# below. Otherwise a launch that already reported its own error is followed by
Expand All @@ -1823,7 +1810,6 @@ def default(
def _launch_managed_default(
ctx: typer.Context,
*,
dry_run: bool,
skip_preflight: bool,
workspace: str | None,
) -> None:
Expand All @@ -1845,22 +1831,9 @@ def _launch_managed_default(
"--skip-preflight launches with your own settings, so `ucode` has no managed config "
"to pick an agent from. Run `ucode <agent> --skip-preflight` instead."
)
# --dry-run avoids the fetch but still applies the last saved config.
if dry_run:
managed = load_managed_state(current)
else:
with spinner("Checking for a managed coding agent config..."):
managed = refresh_managed_config(state)
with spinner("Checking for a managed coding agent config..."):
managed = refresh_managed_config(state)
if not managed:
# Only a read that actually reached the workspace can say it publishes no config. Under
# --dry-run nothing was fetched, so an empty cache means "not pulled yet" — reporting that
# as "no config" would tell an admin their own published config doesn't exist.
if dry_run:
print_warning(
"No managed coding agent config is saved locally yet, so there is nothing to "
"dry-run. Run `ucode` without --dry-run to pull your workspace's config first."
)
return
_print_no_managed_config_guidance(current, state.get("profile"))
return
# The budget tier can move the org to a cheaper agent, so it outranks the config's
Expand Down Expand Up @@ -2506,15 +2479,10 @@ def setup(
"ucode's manifest shape) instead. Validated before it is saved.",
),
] = None,
dry_run: Annotated[
bool,
typer.Option("--dry-run", help="Walk the flow without writing any files."),
] = False,
) -> None:
"""Author the managed coding config for your workspace (workspace admins only)."""
if ctx.invoked_subcommand is not None:
return
set_dry_run(dry_run)
# `typer.Exit` subclasses RuntimeError, so it must be raised outside the try — inside, the
# `except RuntimeError` below would swallow it and report the exit code as an error message.
try:
Expand Down
12 changes: 6 additions & 6 deletions src/ucode/managed_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@

There is deliberately one file, not a separate authored ``managed-settings.json``: the workspace is
the source of truth, so an authored draft and the pulled copy are the same shape and coexist in
``managed-state.json``. ``ucode --dry-run`` reads the local draft without fetching or overwriting it,
which is how an admin tries a config out between ``ucode setup`` and ``ucode apply``.
``managed-state.json``. ``ucode setup`` authors the draft; ``ucode apply`` publishes it; a launch
then pulls the published copy back into the same file.

:func:`refresh_managed_config` is the launch path's entry point. It is called before model discovery,
because the manifest decides whether that discovery is needed at all; the launch path then hands the
Expand Down Expand Up @@ -379,10 +379,10 @@ def load_managed_state(workspace: str | None) -> dict | None:
Returns the normalized config dict (the ``config`` field), only when the stored file is for the
same workspace — so a stale file from another workspace is ignored rather than misapplied.

This is the single local managed config: ``ucode setup`` authors it here, ``ucode --dry-run``
reads it to try the authored config locally, ``ucode apply`` publishes it, and a normal launch
refreshes it from the workspace. The admin-authored draft and the pulled copy share one file
because the workspace is the source of truth — to keep a draft, publish it with ``ucode apply``.
This is the single local managed config: ``ucode setup`` authors it here, ``ucode apply``
publishes it, and a launch refreshes it from the workspace. The admin-authored draft and the
pulled copy share one file because the workspace is the source of truth — to keep a draft,
publish it with ``ucode apply``.
"""
if not workspace:
return None
Expand Down
34 changes: 8 additions & 26 deletions src/ucode/managed_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@
Workspace admins run this to build the ``CodingAgentConfig`` their developers will pull. It walks
the admin through agents, per-agent models, tracing, MCP servers, skills, and a spend-routing budget
policy, then writes the manifest to ``~/.ucode/managed-state.json`` (the one local managed-config
file, owned by :mod:`ucode.managed_config`). An admin can try it with ``ucode --dry-run`` and then
publish it to the workspace with ``ucode apply`` (a separate command, so the file can be reviewed
first).
file, owned by :mod:`ucode.managed_config`). Publishing it to the workspace is ``ucode apply`` (a
separate command, so an admin can review the file first).

Serialization, validation, and the per-agent model catalogs live in :mod:`ucode.managed_setup`; this
module is the interaction layer on top of them. Sub-flows an admin already knows — tracing, MCP,
Expand All @@ -20,7 +19,6 @@
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,
all_users_can_use_schema,
Expand Down Expand Up @@ -936,7 +934,7 @@ def _delete_existing_config(workspace: str, token: str, existing: dict) -> None:
"""Delete the workspace's published config after confirming. Raises RuntimeError on failure.

Deleting leaves the workspace with no managed config, so every developer falls back to their own
settings on their next ucode run — confirm before doing it, and honor ``--dry-run``.
settings on their next ucode run — confirm before doing it.
"""
name = existing.get("name")
if not isinstance(name, str):
Expand All @@ -951,9 +949,6 @@ def _delete_existing_config(workspace: str, token: str, existing: dict) -> None:
if not prompt_yes_no_default("Delete the existing managed config?", default=False):
print_note("Nothing was deleted.")
return
if is_dry_run():
print_success("Dry run: the config was not deleted.")
return
with spinner("Deleting the managed config..."):
delete_reason = delete_coding_agent_config(workspace, token, name)
if delete_reason is not None:
Expand Down Expand Up @@ -998,23 +993,14 @@ def setup_from_file(path: str) -> int:

save_managed_state(workspace, manifest)
_render_summary(workspace, manifest)
if is_dry_run():
print_success(
f"Dry run: {manifest_path.name} was not written to ~/.ucode/managed-state.json."
)
else:
print_success(f"Saved to {manifest_path.name} -> ~/.ucode/managed-state.json")
_print_next_steps()
print_success(f"Saved to {manifest_path.name} -> ~/.ucode/managed-state.json")
_print_next_steps()
return 0


def _print_next_steps() -> None:
console.print()
print_heading("Next steps")
# The authored manifest is saved to the same local file a launch reads, so `ucode --dry-run`
# previews this machine's agents *as configured by the manifest* without fetching or overwriting
# it — a real local test of the config before it is published.
print_note("Try it locally: ucode --dry-run")
print_note("Publish it to the workspace: ucode apply")


Expand Down Expand Up @@ -1056,8 +1042,7 @@ def setup_command(
if not _handle_existing_config(workspace, token):
return 0

# Discover the workspace's models and gateway URLs. This also logs in and persists local state,
# which is what lets the admin dry-run the config on their own machine afterwards.
# Discover the workspace's models and gateway URLs. This also logs in and persists local state.
state = configure_shared_state(workspace, profile=profile, force_login=False)
workspace = state.get("workspace") or workspace
profile = state.get("profile") or profile
Expand Down Expand Up @@ -1159,11 +1144,8 @@ def setup_command(
save_managed_state(workspace, manifest)
_render_summary(workspace, manifest)
console.print()
if is_dry_run():
print_success("Dry run: nothing was written to ~/.ucode/managed-state.json.")
else:
print_success("Saved to ~/.ucode/managed-state.json")
_print_next_steps()
print_success("Saved to ~/.ucode/managed-state.json")
_print_next_steps()
return 0


Expand Down
19 changes: 0 additions & 19 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2584,25 +2584,6 @@ def test_non_admin_without_a_config_is_told_to_ask(self, monkeypatch):
assert launched == []
assert "Ask a workspace admin" in result.output

def test_dry_run_uses_the_cache_and_does_not_fetch(self, monkeypatch):
monkeypatch.setenv("ENABLE_MANAGED_AGENT_CONFIG", "1")
monkeypatch.setattr("ucode.cli.install_databricks_cli", lambda *a, **k: None)
monkeypatch.setattr("ucode.cli.apply_pat_environment", lambda *a, **k: None)
monkeypatch.setattr("ucode.cli.load_state", lambda: {"workspace": "https://w"})
monkeypatch.setattr(
"ucode.cli.refresh_managed_config",
lambda state: pytest.fail("--dry-run must not fetch"),
)
monkeypatch.setattr("ucode.cli.load_managed_state", lambda ws: self.MANAGED)
launched: list[tuple] = []
monkeypatch.setattr(
"ucode.cli._launch_tool", lambda tool, ctx, **kw: launched.append((tool, kw))
)
result = runner.invoke(app, ["--dry-run"])
assert result.exit_code == 0, result.output
# The config bare `ucode` already read is handed down, so the launch path does not refetch.
assert launched[0][1]["managed"] == self.MANAGED

def test_skip_preflight_has_no_config_to_pick_an_agent_from(self, monkeypatch):
# --skip-preflight is deliberately unmanaged, so bare `ucode` cannot resolve an agent. It
# must say that rather than report "no config found", which would be wrong.
Expand Down
26 changes: 0 additions & 26 deletions tests/test_managed_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,6 @@ def test_choosing_delete_stops_and_deletes(self):
),
patch.object(wizard, "prompt_for_selection", return_value="delete"),
patch.object(wizard, "prompt_yes_no_default", return_value=True),
patch.object(wizard, "is_dry_run", return_value=False),
patch.object(wizard, "delete_coding_agent_config", return_value=None) as delete,
):
assert wizard._handle_existing_config(WORKSPACE, "token") is False
Expand All @@ -300,21 +299,6 @@ def test_delete_declined_leaves_config_intact(self):
assert wizard._handle_existing_config(WORKSPACE, "token") is False
assert not delete.called

def test_delete_honors_dry_run(self):
with (
patch.object(
wizard,
"get_managed_config",
return_value=({"name": "cfg/1", "enabled_agents": {}}, None),
),
patch.object(wizard, "prompt_for_selection", return_value="delete"),
patch.object(wizard, "prompt_yes_no_default", return_value=True),
patch.object(wizard, "is_dry_run", return_value=True),
patch.object(wizard, "delete_coding_agent_config") as delete,
):
assert wizard._handle_existing_config(WORKSPACE, "token") is False
assert not delete.called

def test_delete_failure_raises(self):
with (
patch.object(
Expand All @@ -324,7 +308,6 @@ def test_delete_failure_raises(self):
),
patch.object(wizard, "prompt_for_selection", return_value="delete"),
patch.object(wizard, "prompt_yes_no_default", return_value=True),
patch.object(wizard, "is_dry_run", return_value=False),
patch.object(wizard, "delete_coding_agent_config", return_value="HTTP 500"),
pytest.raises(RuntimeError, match="Could not delete"),
):
Expand Down Expand Up @@ -2146,15 +2129,6 @@ def test_from_file_is_forwarded(self):
runner.invoke(app, ["setup", "--from-file", "/tmp/x.json"])
assert setup.call_args.kwargs["from_file"] == "/tmp/x.json"

def test_dry_run_sets_the_flag(self):
with (
patch("ucode.cli.install_databricks_cli"),
patch("ucode.cli.setup_command", return_value=0),
patch("ucode.cli.set_dry_run") as set_flag,
):
runner.invoke(app, ["setup", "--dry-run"])
set_flag.assert_called_once_with(True)

def test_show_exits_zero(self):
with patch("ucode.cli.show_command", return_value=0):
result = runner.invoke(app, ["setup", "show"])
Expand Down
Loading