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
16 changes: 12 additions & 4 deletions src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,10 +262,18 @@ def _maybe_offer_admin_setup(workspace: str, profile: str | None) -> None:
"the agents, models, MCPs, and skills once, and every developer picks them up automatically."
)
if prompt_yes_no("Set one up now with `ucode setup`?"):
print_note(
"Run `ucode setup` to author your workspace's managed config, then `ucode apply`."
)
raise typer.Exit(0)
# Launch the setup flow in place rather than telling them to re-run a command. Reuse the
# workspace/profile we already resolved and authenticated against so setup doesn't prompt
# for them again.
try:
code = setup_command(workspace=workspace, profile=profile)
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
raise typer.Exit(code or 0)


def _print_discovery_diagnostics(state: dict) -> None:
Expand Down
36 changes: 24 additions & 12 deletions src/ucode/managed_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,19 +73,20 @@
spinner,
)

# What `use_as_global_settings` actually does, in plain terms. Admins are choosing between a
# machine-wide managed settings file and a per-user one, which is not obvious from the field name.
# What `use_as_global_settings` actually does, in plain terms. Admins are choosing whether to write
# the agent's own global settings file (so it points at the gateway even when launched directly) or
# a ucode-specific one (so the agent only routes through the gateway when launched via ucode).
GLOBAL_SETTINGS_BLURB = (
"Write this agent's config to the machine's managed settings file, which applies to every "
"user on the machine and cannot be overridden locally. Answer no to write the per-user "
"settings file instead, which developers can still change."
"Answer Yes to write this agent's own global settings file, so it points at the Databricks "
"gateway even when launched directly, without ucode. Answer no to write a ucode-specific "
"settings file instead, so the agent only routes through the gateway when launched via ucode."
)

BUDGET_POLICY_BLURB = (
"A budget policy moves developers onto cheaper agents and models as the workspace spends "
"against a budget — for example Claude Code on Opus by default, then Sonnet at 80%, then "
"OpenCode on Kimi at 100%. It only changes the default; developers can still pick anything "
"they have access to. Hard caps stay with the budget's own blocking threshold."
"OpenCode on Kimi at 100%. It only changes the default; developers can still pick any Model "
"Service to which they have access. Hard caps stay with the budget's own blocking threshold."
)


Expand Down Expand Up @@ -355,7 +356,7 @@ def _prompt_models_for_agent(tool: str, state: dict, provider_service: dict | No
if tool in SINGLE_MODEL_AGENTS:
return {
"default_model": _require_selection(
f"Select the model for {display}:", [(model, model) for model in options]
f"Select the default model for {display}:", [(model, model) for model in options]
)
}

Expand Down Expand Up @@ -820,7 +821,7 @@ def _render_summary(workspace: str, manifest: dict) -> None:
provider = model_config.get("model_provider_service")
if provider:
detail = f"{detail} via {provider}"
scope = "machine-wide" if agent_config.get("use_as_global_settings") else "per-user"
scope = "global settings" if agent_config.get("use_as_global_settings") else "ucode-only"
lines.append(kv_line(display, f"{detail} ({scope})"))
# Spell out the per-family slots and model lists: the one-line default alone doesn't show
# which families an admin configured, which is most of what they chose for claude.
Expand Down Expand Up @@ -1008,9 +1009,18 @@ def _print_next_steps() -> None:
print_note("Publish it to the workspace: ucode apply")


def setup_command(from_file: str | None = None) -> int:
def setup_command(
from_file: str | None = None,
*,
workspace: str | None = None,
profile: str | None = None,
) -> int:
"""Author the workspace's managed coding-agent config interactively.

``workspace``/``profile`` let a caller that has already resolved (and authenticated against) a
workspace hand it in so the admin isn't prompted to pick one again — e.g. `ucode configure`
launching setup after its admin offer. When ``workspace`` is None the flow prompts as usual.

Returns a process exit code. Raises RuntimeError for actionable failures (not an admin, no
agents available) and KeyboardInterrupt when the admin aborts a picker; the CLI maps both.
"""
Expand All @@ -1025,7 +1035,8 @@ def setup_command(from_file: str | None = None) -> int:
print_note("Author the managed coding config for this workspace.")
print_note("Developers pull it automatically when they run ucode.")

workspace, profile = _prompt_for_configuration()
if workspace is None:
workspace, profile = _prompt_for_configuration()
# `configure_shared_state` below authenticates too and prints its own success line, so this one
# stays quiet rather than reporting the same thing twice. It still has to run first: the admin
# gate and the existing-config check both need a token before discovery.
Expand Down Expand Up @@ -1082,7 +1093,8 @@ def setup_command(from_file: str | None = None) -> int:
"model_config": _prompt_models_for_agent(tool, state, provider_service)
}
agent_config["use_as_global_settings"] = prompt_yes_no_default(
f"Apply {TOOL_SPECS[tool]['display']} config machine-wide? ({GLOBAL_SETTINGS_BLURB})",
f"Write {TOOL_SPECS[tool]['display']}'s config to its global settings file? "
f"({GLOBAL_SETTINGS_BLURB})",
default=False,
)
enabled_agents[tool] = agent_config
Expand Down
29 changes: 28 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2373,7 +2373,9 @@ def test_admin_who_declines_proceeds_with_configure(self, monkeypatch):
entries = self._resolve([("https://w", None)])
assert entries == [("https://w", None)]

def test_admin_who_accepts_exits_to_run_setup(self, monkeypatch):
def test_admin_who_accepts_launches_setup_in_place(self, monkeypatch):
# Accepting the offer runs `setup_command` right there — reusing the workspace/profile we
# already resolved so setup doesn't re-prompt for them — then exits with its code.
import typer

monkeypatch.setenv("ENABLE_MANAGED_AGENT_CONFIG", "1")
Expand All @@ -2382,9 +2384,34 @@ def test_admin_who_accepts_exits_to_run_setup(self, monkeypatch):
monkeypatch.setattr("ucode.cli.get_databricks_token", lambda ws, profile=None: "tok")
monkeypatch.setattr("ucode.cli.is_workspace_admin", lambda ws, tok: True)
monkeypatch.setattr("ucode.cli.prompt_yes_no", lambda prompt: True)
calls = {}
monkeypatch.setattr(
"ucode.cli.setup_command",
lambda **kwargs: calls.update(kwargs) or 0,
)
with pytest.raises(typer.Exit) as exc:
self._resolve([("https://w", None)])
assert exc.value.exit_code == 0
assert calls == {"workspace": "https://w", "profile": None}

def test_admin_who_accepts_propagates_setup_failure(self, monkeypatch):
# A RuntimeError from setup (e.g. discovery failed) surfaces as a non-zero exit, not a stack
# trace bubbling out of configure.
import typer

monkeypatch.setenv("ENABLE_MANAGED_AGENT_CONFIG", "1")
monkeypatch.setattr("ucode.cli.set_current_workspace", lambda ws: None)
monkeypatch.setattr("ucode.cli.refresh_managed_config", lambda state: None)
monkeypatch.setattr("ucode.cli.get_databricks_token", lambda ws, profile=None: "tok")
monkeypatch.setattr("ucode.cli.is_workspace_admin", lambda ws, tok: True)
monkeypatch.setattr("ucode.cli.prompt_yes_no", lambda prompt: True)
monkeypatch.setattr(
"ucode.cli.setup_command",
lambda **kwargs: (_ for _ in ()).throw(RuntimeError("no agents")),
)
with pytest.raises(typer.Exit) as exc:
self._resolve([("https://w", None)])
assert exc.value.exit_code == 1

def test_non_admin_is_never_prompted_for_setup(self, monkeypatch):
monkeypatch.setenv("ENABLE_MANAGED_AGENT_CONFIG", "1")
Expand Down
Loading