Skip to content

Add ucode setup: interactive managed-config authoring - #268

Merged
tt-le merged 7 commits into
tien/managed-setup-serializerfrom
tien/managed-setup-wizard
Aug 6, 2026
Merged

Add ucode setup: interactive managed-config authoring#268
tt-le merged 7 commits into
tien/managed-setup-serializerfrom
tien/managed-setup-wizard

Conversation

@tt-le

@tt-le tt-le commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Demo

Screen.Recording.2026-08-06.at.11.01.02.AM.mov

Changes

Stacked on #267 — review that one first. This PR's diff includes it until #267 merges.

The admin-facing wizard on top of #267's serializer. Walks a workspace admin through agents, per-agent models, tracing, MCP servers, skills, and a spend-routing budget policy, then writes the manifest to ~/.ucode/managed-settings.json. Publishing is a separate ucode apply (next PR) so an admin can review the file first — and ucode setup show prints both the summary and the exact proto-JSON that apply will POST.

managed_wizard.py is the interaction layer only; catalogs, validation, and serialization stay in managed_setup.py. Sub-flows an admin already knows are delegated to the existing commands (configure_tracing_command, configure_mcp_command, configure_skills_mcp_command) and their results read back out of state.json, so there is still exactly one picker per concern.

Claude Code is prompted per model family

ClaudeModelConfig.models is a ClaudeDefaultModels message — four optional slots (default_opus_model / default_sonnet_model / default_haiku_model / default_fable_model), since Claude Code addresses models by family alias rather than from a list. A flat multi-select cannot express that.

? Default opus model:
  › system.ai.claude-opus-5
    system.ai.claude-opus-4-8
    system.ai.claude-opus-4-7
    ... (skip opus)

Each family is skippable (the slots are optional, and an unset one falls back to default_model); the overall default_model is then chosen from the slots that were filled, so it can never name a model the config doesn't carry. With one slot filled there is nothing to choose, so it reports what it inferred rather than asking a single-option question.

That required looking past state["claude_models"], which is {family: newest} because the launch path pins one model per alias — the workspace tested against has 12 Claude models and the bucketed view shows 4. discover_claude_models_unbucketed returns the full listing, claude_family_candidates groups it by family, and _known_models reads the same set so validation cannot reject a version the prompts offered. discover_model_services is untouched — every agent's config writer depends on the shape of claude_models.

Other model-prompt behaviour

Provider-service agents get a real picker. list_model_provider_services already returns each service's config.targets; the wizard now selects the whole service dict and offers those, instead of an empty text prompt that required knowing a provider-side id by heart. allow_all_targets and relayed Anthropic subscription services have nothing to enumerate, so those fall back to free text — but say why and give an example.

Gemini and Copilot get one model, not a multi-select: their writers take a single model and set one env var, so extra picks were silently discarded at apply time. Wizard-level only — both protos declare repeated string models, so --from-file still serializes a list. OpenCode and Pi keep their multi-select; they really do show a model picker.

Nothing is pre-checked. options[0] is whatever discovery sorted first, not a recommendation — for Pi a Claude model, for Codex the oldest GPT. It only existed to make Enter work, which stopped mattering once every agent required a model: an empty selection is re-prompted. That also makes _prompt_models_for_agent total. Without it, picking a multi-model agent and hitting Enter produced an agent with no model_config, and if it was the default_agent the run died at the end — after tracing, MCP, skills, and budget had all been answered.

Budget tiers offer only the agent's own models

The picker previously read the workspace catalog and called load_state(), so it could not have seen this run's choices even in principle — configuring Pi with one Kimi model still offered every family. Picking one of the extras was not harmless: validateStoredConfig checks only that a tier's default_agent is in enabled_agents, never that the agent has the model, so the tier would activate and hand a developer a model their agent was never given. validate_manifest gains that missing rule, which is the durable half — it guards --from-file and hand-written manifests too.

Tiers are prompted in percent and stored as fractions, keeping the spec-vs-API units mismatch in one place.

Smaller notes

Model ids stay bare (system.ai.claude-opus-4-8), not provider-prefixed: each agent's writer adds whatever its config format needs, so the manifest stays agent-neutral.

Two readback details: tracing is stored as tracing.uc_destination but the managed config calls it tracing.table, and an enabled-but-not-UC-backed experiment has no table so it is omitted rather than published empty; state.json records each MCP server's URL but not its type, so _mcp_type_for_url maps the URL shape back and skips anything unrecognized instead of guessing.

databricks.py adds is_workspace_admin (SCIM Me group membership) and list_workspace_budgets (workspace-scoped — ucode never creates budgets, an admin picks an existing one). The admin gate errors on a definite non-admin and warns-then-continues when the check itself fails, since the API enforces the same rule server-side.

Testing

uv run pytest — 1334 passed, 36 skipped. 116 new cases. (tests/test_e2e_user_agent.py::test_user_agent_arrives_at_gateway fails on my machine because /etc/claude-code/managed-settings.json overrides ANTHROPIC_BASE_URL past the test's capture server; it fails on main too and is unrelated.)

Several are mutation-checked: reverting the claude flat multi-select fails eight, reverting the budget-tier picker fails test_offers_only_the_models_the_agent_was_configured_with, and dropping the tier-model validation rule fails test_tier_model_must_be_one_the_agent_has.

Verified interactively against a staging workspace: six opus versions and four sonnet offered, a skipped family absent from models, and the result round-trips through normalize_managed_config to the proto shape.

Known gap

discover_model_services and discover_claude_models_unbucketed both page /api/2.1/unity-catalog/model-services, so a run that configures Claude lists it twice (one extra spinner). _claude_candidates caches on state["all_claude_models"], bounding it to once per run. Removing the second listing means widening discover_model_services' 5-tuple return, which is asserted in eight tests and mocked in sixteen — left for a follow-up rather than growing this PR.

This pull request and its description were written by Isaac.

tt-le added 2 commits August 5, 2026 17:52
The admin-facing wizard on top of the serializer added in the parent commit.
Walks a workspace admin through agents, per-agent models, tracing, MCP servers,
skills, and a spend-routing budget policy, then writes the manifest to
`~/.ucode/managed-settings.json`. Publishing is a separate `ucode apply` (next
change) so an admin can review the file first — and `ucode setup show` prints both
the summary and the exact proto-JSON that apply will POST.

`managed_wizard.py` is the interaction layer only; catalogs, validation, and
serialization stay in `managed_setup.py`. Sub-flows an admin already knows are
delegated to the existing commands (`configure_tracing_command`,
`configure_mcp_command`, `configure_skills_mcp_command`) and their results read back
out of `state.json`, so there is still exactly one picker per concern.

Claude Code is prompted per model family, not as a flat list. `ClaudeModelConfig.models`
is a `ClaudeDefaultModels` message — four optional slots (`default_opus_model` /
`default_sonnet_model` / `default_haiku_model` / `default_fable_model`), since Claude
Code addresses models by family alias rather than from a list, and a flat multi-select
cannot express that. Each family is skippable (the slots are `optional`, and an unset
one falls back to `default_model`); the overall `default_model` is then chosen from the
slots that were filled, so it can never name a model the config doesn't carry. With one
slot filled there is nothing to choose, so it reports what it inferred rather than
asking a single-option question.

That required looking past `state["claude_models"]`, which is `{family: newest}` because
the launch path pins one model per alias — the workspace tested against has 12 Claude
models and the bucketed view shows 4. `discover_claude_models_unbucketed` returns the
full `system.ai.claude-*` listing, `claude_family_candidates` groups it by family, and
`_known_models` reads the same set so validation cannot reject a version the prompts
offered. `discover_model_services` is untouched — every agent's config writer depends on
the shape of `claude_models`.

Provider-service agents get a real picker too. `list_model_provider_services` already
returns each service's `config.targets` (the provider-side model ids); the wizard now
selects the whole service dict and offers those, instead of an empty text prompt that
required knowing a provider-side id by heart. Two cases legitimately have nothing to
enumerate — `allow_all_targets`, and a relayed Anthropic subscription service that
routes by canonical name — and those fall back to free text, but say why and give an
example.

Gemini and Copilot get one model rather than a multi-select: their writers take a single
model and set one env var, so extra picks were silently discarded at apply time. This is
a wizard restriction, not a serializer one — both protos do declare `repeated string
models`, so `_FLAT_MODEL_LIST_AGENTS` is unchanged and a hand-written `--from-file`
manifest still serializes a list. OpenCode and Pi keep their multi-select; they really
do show a model picker.

Nothing is pre-checked in the model multi-selects. `options[0]` is whatever discovery
sorted first, not a recommendation — for Pi a Claude model, for Codex the oldest GPT —
so pre-checking it made "hit Enter" produce an arbitrary config and read as an
endorsement. It only existed to make Enter work at all, which stopped mattering once
every agent required a model: an empty selection is re-prompted
(`_require_selection` / `_require_multi_selection` / `_require_text`), which also makes
`_prompt_models_for_agent` total. Without that, picking a multi-model agent and hitting
Enter produced an agent with no `model_config`, and if it was the `default_agent` the run
died at the *end* — after tracing, MCP, skills, and budget had all been answered. Ctrl-C
still aborts.

Budget tiers offer only the models their agent was configured with, via
`configured_models_for_agent`. The picker previously read the workspace catalog and
called `load_state()`, so it could not have seen this run's choices even in principle —
configuring Pi with one Kimi model still offered every family. Picking one of the extras
was not harmless: `validateStoredConfig` checks only that a tier's `default_agent` is in
`enabled_agents`, never that the agent has the model, so the tier would activate and hand
a developer a model their agent was never given. `validate_manifest` gains that missing
rule, which is the durable half — it guards `--from-file` and hand-written manifests too.
Tiers are prompted in percent and stored as fractions (`prompt_for_percentage` converts),
keeping the spec-vs-API units mismatch in one place.

Model ids stay bare (`system.ai.claude-opus-4-8`), not provider-prefixed: each agent's
writer adds whatever its config format needs (see `opencode._resolve_model_selector`), so
the manifest stays agent-neutral and matches the spec's examples.

Two readback details worth noting:
- Tracing is stored as `tracing.uc_destination` but the managed config calls it
  `tracing.table`; an enabled-but-not-UC-backed experiment has no table, so it is
  omitted rather than published empty.
- `state.json` records each MCP server's resolved URL but not its type, while the
  manifest needs `{name, type}`. `_mcp_type_for_url` maps the URL shape back to a type
  and skips anything unrecognized instead of guessing.

`databricks.py`: `is_workspace_admin` (SCIM `Me` group membership) and
`list_workspace_budgets` (workspace-scoped, so no account auth is needed — ucode never
creates budgets, an admin picks an existing one). The admin gate errors on a definite
non-admin and warns-then-continues when the check itself fails, since the API enforces
the same rule server-side.

`ui.py`: `preselected`/`prompt` on `prompt_for_tools`, plus `prompt_for_multi_selection`,
`prompt_for_text`, and `prompt_for_percentage`. Reuses the existing
`prompt_yes_no_default` rather than adding a second defaulting yes/no.

README gains a "Managed config for a workspace (admins)" section, `setup` rows in Other
Commands, and `~/.ucode/managed-settings.json` in Managed Local Files. It states plainly
that the wizard leaves your agent configs alone *except* when you accept the tracing /
MCP / skills sub-steps, which do configure this machine — the one thing about the flow
that isn't obvious.

Verified interactively against eng-ml-inference.staging: six opus versions and four
sonnet offered, a skipped family absent from `models`, and the result round-trips through
`normalize_managed_config` to the proto shape.

Tests: 116 cases. Several are mutation-checked — reverting the claude flat multi-select
fails eight, reverting the budget-tier picker fails
`test_offers_only_the_models_the_agent_was_configured_with`, and dropping the tier-model
validation rule fails `test_tier_model_must_be_one_the_agent_has`. Note `typer.Exit`
subclasses RuntimeError, so raising it inside the command's try block made a successful
run print "ERROR 0"; fixed by exiting after the handler, with
`test_successful_setup_exits_zero` failing if that regresses.

Known gap: `discover_model_services` and `discover_claude_models_unbucketed` both page
`/api/2.1/unity-catalog/model-services`, so a run that configures Claude lists it twice.
`_claude_candidates` caches on `state["all_claude_models"]`, which bounds it to once per
run, but removing the second listing means widening `discover_model_services`' 5-tuple
return — asserted in eight tests and mocked in sixteen — so it is left for a follow-up.

Co-authored-by: Isaac
Two CI failures, both passing locally for environment reasons.

`_claude_candidates` guarded its catalog fetch with `except RuntimeError`, but
`get_databricks_token` shells out to `databricks auth token` — so a machine
without the CLI on PATH raises `FileNotFoundError`, which is an `OSError` and
sailed straight through. This is a real bug, not just a test artifact: any user
without the Databricks CLI installed hit an uncaught traceback mid-wizard
instead of degrading to the bucketed per-family picks. Now catches `OSError`
too. `test_claude_candidates_survive_a_missing_databricks_cli` covers it, and
`test_every_agent_always_gets_a_default_model` — which reached the network on
its claude pass — now stubs the fetch rather than depending on the host.

`test_setup_help_lists_from_file` grepped `--from-file` out of rendered `--help`
output. Rich ellipsizes option names to fit the terminal, so below roughly 40
columns it renders `--fro…`; hosted runners report no width and get the narrow
fallback. It now asserts on the declared Click option, which no amount of
wrapping can change, and still checks the help renders successfully.

Verified by reverting each fix in turn: the first fails with the same
`FileNotFoundError: 'databricks'` CI reported, the second with the same missing
`--from-file` assertion at `COLUMNS=30`.

Co-authored-by: Isaac
@tt-le
tt-le force-pushed the tien/managed-setup-wizard branch from 48d9066 to b078138 Compare August 5, 2026 17:53

@AarushiShah-db AarushiShah-db left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some high level comments based on the video

  • Configuration Summary - can we put a box around it
Image
  • Dry run: is there a reason why its ucode configure --dry-run? Can it just be ucode —dry-run?
  • Warning message update: "A published Claude configuration already exists for this workspace. Publishing will replace it. Make sure this configuration includes everything you want to keep."
  • For the model picker , can we add option to filter by search (you type and it will filter the models) Same with budget policy tracker
  • What is the initial "Fetching available models" step doing and is that needed? because aren't we doing individual API calls anyways to get the models for the individual agents?

tt-le added a commit that referenced this pull request Aug 5, 2026
Review feedback on #268.

The configuration summary is boxed. It is the one block an admin is meant to read
as a whole and check against what they intended, and it arrives after a long flow
of prompts, where loose key/value lines blend into everything printed before them.
`ui.print_panel` boxes a body (as opposed to `print_section`, which boxes a bare
title) and `ui.kv_line` returns a `print_kv`-styled line for collecting into one.

Every picker in the flow now filters as you type. On the workspace this was built
against, the model lists run to 16 GPT entries and 12 Claude ones, which is more
than is comfortable to arrow through. questionary supports this directly, with one
constraint worth naming: it refuses `use_search_filter` together with `use_jk_keys`
(j and k are search characters), so search costs j/k navigation. Arrow keys still
work, and it is opt-in via a `searchable` flag so short pickers — "Databricks
Hosted vs External Models" — keep j/k.

The existing-config warning now says what actually happens. It claimed "there is
no partial update yet", which was both vague and, once `ucode apply` PATCHes, no
longer true. Publishing is a full replace of the workspace's config, so the warning
says that plainly and names the agents already published, which is what the admin
stands to lose. Field masks and PATCH stay out of it — an admin doesn't need to
reason about the transport.

The duplicate "Fetching available models" spinner is gone. `discover_model_services`
and `discover_claude_models_unbucketed` both page the whole metastore catalog, so a
run that configured Claude walked it twice. Rather than widen
`discover_model_services`' 5-tuple return (asserted in 8 tests, mocked in 19, which
is what made this look expensive last time), the memo goes one layer down in
`list_model_services`, where both callers already converge — so no signature
changes and no stub churn. Cached per process and per workspace; failures are never
cached, so a transient error still retries; `use_cache=False` forces a fresh walk.
`conftest` clears it between tests so a cached listing can't leak into a test that
stubs the endpoint.

Measured against eng-ml-inference.staging: 12 HTTP pages for both callers together,
down from 24.

Tests: +9. Mutation-verified — dropping the cache read fails two cache tests,
dropping `searchable` fails the picker test, and un-boxing the summary fails the
panel test.

Deferred, with Aarushi's agreement: the dry-run hint still points at
`ucode configure --dry-run`. The intent is a root `ucode --dry-run` that launches
the default agent against the authored managed config, which needs both that flag
and bare-`ucode` launch behavior that doesn't exist yet.

Co-authored-by: Isaac
tt-le added a commit that referenced this pull request Aug 5, 2026
Review feedback on #268.

The configuration summary is boxed. It is the one block an admin is meant to read
as a whole and check against what they intended, and it arrives after a long flow
of prompts, where loose key/value lines blend into everything printed before them.
`ui.print_panel` boxes a body (as opposed to `print_section`, which boxes a bare
title) and `ui.kv_line` returns a `print_kv`-styled line for collecting into one.

Every picker in the flow now filters as you type. On the workspace this was built
against, the model lists run to 16 GPT entries and 12 Claude ones, which is more
than is comfortable to arrow through. questionary supports this directly, with one
constraint worth naming: it refuses `use_search_filter` together with `use_jk_keys`
(j and k are search characters), so search costs j/k navigation. Arrow keys still
work, and it is opt-in via a `searchable` flag so short pickers — "Databricks
Hosted vs External Models" — keep j/k.

The existing-config warning now says what actually happens. It claimed "there is
no partial update yet", which was both vague and, once `ucode apply` PATCHes, no
longer true. Publishing is a full replace of the workspace's config, so the warning
says that plainly and names the agents already published, which is what the admin
stands to lose. Field masks and PATCH stay out of it — an admin doesn't need to
reason about the transport.

The duplicate "Fetching available models" spinner is gone. `discover_model_services`
and `discover_claude_models_unbucketed` both page the whole metastore catalog, so a
run that configured Claude walked it twice. Rather than widen
`discover_model_services`' 5-tuple return (asserted in 8 tests, mocked in 19, which
is what made this look expensive last time), the memo goes one layer down in
`list_model_services`, where both callers already converge — so no signature
changes and no stub churn. Cached per process and per workspace; failures are never
cached, so a transient error still retries; `use_cache=False` forces a fresh walk.
`conftest` clears it between tests so a cached listing can't leak into a test that
stubs the endpoint.

Measured against eng-ml-inference.staging: 12 HTTP pages for both callers together,
down from 24.

Tests: +9. Mutation-verified — dropping the cache read fails two cache tests,
dropping `searchable` fails the picker test, and un-boxing the summary fails the
panel test.

Deferred, with Aarushi's agreement: the dry-run hint still points at
`ucode configure --dry-run`. The intent is a root `ucode --dry-run` that launches
the default agent against the authored managed config, which needs both that flag
and bare-`ucode` launch behavior that doesn't exist yet.

Co-authored-by: Isaac
@tt-le
tt-le force-pushed the tien/managed-setup-wizard branch from 7fff00b to b8e191a Compare August 5, 2026 20:49
tt-le added a commit that referenced this pull request Aug 5, 2026
Review feedback on #268.

The configuration summary is boxed. It is the one block an admin is meant to read
as a whole and check against what they intended, and it arrives after a long flow
of prompts, where loose key/value lines blend into everything printed before them.
`ui.print_panel` boxes a body (as opposed to `print_section`, which boxes a bare
title) and `ui.kv_line` returns a `print_kv`-styled line for collecting into one.

Every picker in the flow now filters as you type. On the workspace this was built
against, the model lists run to 16 GPT entries and 12 Claude ones, which is more
than is comfortable to arrow through. questionary supports this directly, with one
constraint worth naming: it refuses `use_search_filter` together with `use_jk_keys`
(j and k are search characters), so search costs j/k navigation. Arrow keys still
work, and it is opt-in via a `searchable` flag so short pickers — "Databricks
Hosted vs External Models" — keep j/k.

The existing-config warning now says what actually happens. It claimed "there is
no partial update yet", which was both vague and, once `ucode apply` PATCHes, no
longer true. Publishing is a full replace of the workspace's config, so the warning
says that plainly and names the agents already published, which is what the admin
stands to lose. Field masks and PATCH stay out of it — an admin doesn't need to
reason about the transport.

The duplicate "Fetching available models" spinner is gone. `discover_model_services`
and `discover_claude_models_unbucketed` both page the whole metastore catalog, so a
run that configured Claude walked it twice. Rather than widen
`discover_model_services`' 5-tuple return (asserted in 8 tests, mocked in 19, which
is what made this look expensive last time), the memo goes one layer down in
`list_model_services`, where both callers already converge — so no signature
changes and no stub churn. Cached per process and per workspace; failures are never
cached, so a transient error still retries; `use_cache=False` forces a fresh walk.
`conftest` clears it between tests so a cached listing can't leak into a test that
stubs the endpoint.

Measured against eng-ml-inference.staging: 12 HTTP pages for both callers together,
down from 24.

Tests: +9. Mutation-verified — dropping the cache read fails two cache tests,
dropping `searchable` fails the picker test, and un-boxing the summary fails the
panel test.

Two more redundant lines removed while in here. The "Next steps" block no longer
suggests `ucode configure --dry-run`: the manifest describes what *developers*
should get, while that command previews *this machine's* agent configs, so it
implied a local test it does not perform. Publishing is the only next step there is
today. And the wizard's own `ensure_databricks_auth` is now quiet, because
`configure_shared_state` authenticates a moment later and prints the same "auth
already available" success — the wizard's call still has to run first, since the
admin gate and the existing-config check both need a token before discovery.

Deferred, with Aarushi's agreement: a root `ucode --dry-run` that launches the
default agent against the authored config, so an admin can try it locally before
publishing. That needs both the flag and bare-`ucode` launch behavior, neither of
which exists yet.

Co-authored-by: Isaac
@tt-le
tt-le force-pushed the tien/managed-setup-wizard branch from b8e191a to 57cb08d Compare August 5, 2026 21:03
tt-le added a commit that referenced this pull request Aug 5, 2026
Review feedback on #268.

The configuration summary is boxed. It is the one block an admin is meant to read
as a whole and check against what they intended, and it arrives after a long flow
of prompts, where loose key/value lines blend into everything printed before them.
`ui.print_panel` boxes a body (as opposed to `print_section`, which boxes a bare
title) and `ui.kv_line` returns a `print_kv`-styled line for collecting into one.

Every picker in the flow now filters as you type. On the workspace this was built
against, the model lists run to 16 GPT entries and 12 Claude ones, which is more
than is comfortable to arrow through. questionary supports this directly, with one
constraint worth naming: it refuses `use_search_filter` together with `use_jk_keys`
(j and k are search characters), so search costs j/k navigation. Arrow keys still
work, and it is opt-in via a `searchable` flag so short pickers — "Databricks
Hosted vs External Models" — keep j/k.

The existing-config warning now says what actually happens. It claimed "there is
no partial update yet", which was both vague and, once `ucode apply` PATCHes, no
longer true. Publishing is a full replace of the workspace's config, so the warning
says that plainly and names the agents already published, which is what the admin
stands to lose. Field masks and PATCH stay out of it — an admin doesn't need to
reason about the transport.

The duplicate "Fetching available models" spinner is gone. `discover_model_services`
and `discover_claude_models_unbucketed` both page the whole metastore catalog, so a
run that configured Claude walked it twice. Rather than widen
`discover_model_services`' 5-tuple return (asserted in 8 tests, mocked in 19, which
is what made this look expensive last time), the memo goes one layer down in
`list_model_services`, where both callers already converge — so no signature
changes and no stub churn. Cached per process and per workspace; failures are never
cached, so a transient error still retries; `use_cache=False` forces a fresh walk.
`conftest` clears it between tests so a cached listing can't leak into a test that
stubs the endpoint.

Measured against eng-ml-inference.staging: 12 HTTP pages for both callers together,
down from 24.

Tests: +9. Mutation-verified — dropping the cache read fails two cache tests,
dropping `searchable` fails the picker test, and un-boxing the summary fails the
panel test.

Two more redundant lines removed while in here. The "Next steps" block no longer
suggests `ucode configure --dry-run`: the manifest describes what *developers*
should get, while that command previews *this machine's* agent configs, so it
implied a local test it does not perform. Publishing is the only next step there is
today. And the wizard's own `ensure_databricks_auth` is now quiet, because
`configure_shared_state` authenticates a moment later and prints the same "auth
already available" success — the wizard's call still has to run first, since the
admin gate and the existing-config check both need a token before discovery.

Deferred, with Aarushi's agreement: a root `ucode --dry-run` that launches the
default agent against the authored config, so an admin can try it locally before
publishing. That needs both the flag and bare-`ucode` launch behavior, neither of
which exists yet.

Co-authored-by: Isaac
@tt-le
tt-le force-pushed the tien/managed-setup-wizard branch from 57cb08d to c536a37 Compare August 5, 2026 21:12
tt-le added a commit that referenced this pull request Aug 5, 2026
Review feedback on #268.

The configuration summary is boxed. It is the one block an admin is meant to read
as a whole and check against what they intended, and it arrives after a long flow
of prompts, where loose key/value lines blend into everything printed before them.
`ui.print_panel` boxes a body (as opposed to `print_section`, which boxes a bare
title) and `ui.kv_line` returns a `print_kv`-styled line for collecting into one.

Every picker in the flow now filters as you type. On the workspace this was built
against, the model lists run to 16 GPT entries and 12 Claude ones, which is more
than is comfortable to arrow through. questionary supports this directly, with one
constraint worth naming: it refuses `use_search_filter` together with `use_jk_keys`
(j and k are search characters), so search costs j/k navigation. Arrow keys still
work, and it is opt-in via a `searchable` flag so short pickers — "Databricks
Hosted vs External Models" — keep j/k.

The existing-config warning now says what actually happens. It claimed "there is
no partial update yet", which was both vague and, once `ucode apply` PATCHes, no
longer true. Publishing is a full replace of the workspace's config, so the warning
says that plainly and names the agents already published, which is what the admin
stands to lose. Field masks and PATCH stay out of it — an admin doesn't need to
reason about the transport.

The duplicate "Fetching available models" spinner is gone. `discover_model_services`
and `discover_claude_models_unbucketed` both page the whole metastore catalog, so a
run that configured Claude walked it twice. Rather than widen
`discover_model_services`' 5-tuple return (asserted in 8 tests, mocked in 19, which
is what made this look expensive last time), the memo goes one layer down in
`list_model_services`, where both callers already converge — so no signature
changes and no stub churn. Cached per process and per workspace; failures are never
cached, so a transient error still retries; `use_cache=False` forces a fresh walk.
`conftest` clears it between tests so a cached listing can't leak into a test that
stubs the endpoint.

Measured against eng-ml-inference.staging: 12 HTTP pages for both callers together,
down from 24.

Tests: +9. Mutation-verified — dropping the cache read fails two cache tests,
dropping `searchable` fails the picker test, and un-boxing the summary fails the
panel test.

Two more redundant lines removed while in here. The "Next steps" block no longer
suggests `ucode configure --dry-run`: the manifest describes what *developers*
should get, while that command previews *this machine's* agent configs, so it
implied a local test it does not perform. Publishing is the only next step there is
today. And the wizard's own `ensure_databricks_auth` is now quiet, because
`configure_shared_state` authenticates a moment later and prints the same "auth
already available" success — the wizard's call still has to run first, since the
admin gate and the existing-config check both need a token before discovery.

Deferred, with Aarushi's agreement: a root `ucode --dry-run` that launches the
default agent against the authored config, so an admin can try it locally before
publishing. That needs both the flag and bare-`ucode` launch behavior, neither of
which exists yet.

Co-authored-by: Isaac
@tt-le
tt-le force-pushed the tien/managed-setup-wizard branch from c536a37 to 0d02f98 Compare August 5, 2026 21:21
tt-le added a commit that referenced this pull request Aug 5, 2026
Review feedback on #268.

The configuration summary is boxed. It is the one block an admin is meant to read
as a whole and check against what they intended, and it arrives after a long flow
of prompts, where loose key/value lines blend into everything printed before them.
`ui.print_panel` boxes a body (as opposed to `print_section`, which boxes a bare
title) and `ui.kv_line` returns a `print_kv`-styled line for collecting into one.

Every picker in the flow now filters as you type. On the workspace this was built
against, the model lists run to 16 GPT entries and 12 Claude ones, which is more
than is comfortable to arrow through. questionary supports this directly, with one
constraint worth naming: it refuses `use_search_filter` together with `use_jk_keys`
(j and k are search characters), so search costs j/k navigation. Arrow keys still
work, and it is opt-in via a `searchable` flag so short pickers — "Databricks
Hosted vs External Models" — keep j/k.

The existing-config warning now says what actually happens. It claimed "there is
no partial update yet", which was both vague and, once `ucode apply` PATCHes, no
longer true. Publishing is a full replace of the workspace's config, so the warning
says that plainly and names the agents already published, which is what the admin
stands to lose. Field masks and PATCH stay out of it — an admin doesn't need to
reason about the transport.

The existing-config warning also says what it is warning about. It named the
enabled agents in a parenthetical — "already exists for this workspace (claude)" —
which read as though the workspace itself were called claude, and listing the
contents implied the config was per-agent when there is exactly one per workspace
covering agents, MCP servers, skills, tracing, and the budget policy. It now says
that scope plainly and itemizes nothing: an inventory doesn't change what the admin
should do, and `ucode setup show` prints the real thing for anyone comparing.

The duplicate "Fetching available models" spinner is gone. `discover_model_services`
and `discover_claude_models_unbucketed` both page the whole metastore catalog, so a
run that configured Claude walked it twice. Rather than widen
`discover_model_services`' 5-tuple return (asserted in 8 tests, mocked in 19, which
is what made this look expensive last time), the memo goes one layer down in
`list_model_services`, where both callers already converge — so no signature
changes and no stub churn. Cached per process and per workspace; failures are never
cached, so a transient error still retries; `use_cache=False` forces a fresh walk.
`conftest` clears it between tests so a cached listing can't leak into a test that
stubs the endpoint.

The Model Provider Service listing gets the same treatment, for the same reason: it
is workspace-wide and filtered per agent afterwards, so the wizard re-listed it once
per MPS-capable agent. One call now serves them all. Cached entries are copied on
both store and read, since the wizard treats the list as its own.

Measured against eng-ml-inference.staging: 12 HTTP pages for the model catalog
across both callers, down from 24; and one MPS listing for claude + codex, down from
two.

Tests: +15. Mutation-verified — dropping either cache read fails a cache test,
dropping `searchable` fails the picker test, un-boxing the summary fails the panel
test, and re-adding the config inventory fails the warning test. The MPS aliasing
guard is split in two on purpose: the store-side and read-side copies each need
their own case, since a single test passed with either one still present.

Two more redundant lines removed while in here. The "Next steps" block no longer
suggests `ucode configure --dry-run`: the manifest describes what *developers*
should get, while that command previews *this machine's* agent configs, so it
implied a local test it does not perform. Publishing is the only next step there is
today. And the wizard's own `ensure_databricks_auth` is now quiet, because
`configure_shared_state` authenticates a moment later and prints the same "auth
already available" success — the wizard's call still has to run first, since the
admin gate and the existing-config check both need a token before discovery.

Deferred, with Aarushi's agreement: a root `ucode --dry-run` that launches the
default agent against the authored config, so an admin can try it locally before
publishing. That needs both the flag and bare-`ucode` launch behavior, neither of
which exists yet.

Co-authored-by: Isaac
@tt-le
tt-le force-pushed the tien/managed-setup-wizard branch from 0d02f98 to 7f8ac63 Compare August 5, 2026 22:13
Review feedback on #268.

The configuration summary is boxed. It is the one block an admin is meant to read
as a whole and check against what they intended, and it arrives after a long flow
of prompts, where loose key/value lines blend into everything printed before them.
`ui.print_panel` boxes a body (as opposed to `print_section`, which boxes a bare
title) and `ui.kv_line` returns a `print_kv`-styled line for collecting into one.

Every picker in the flow now filters as you type. On the workspace this was built
against, the model lists run to 16 GPT entries and 12 Claude ones, which is more
than is comfortable to arrow through. questionary supports this directly, with one
constraint worth naming: it refuses `use_search_filter` together with `use_jk_keys`
(j and k are search characters), so search costs j/k navigation. Arrow keys still
work, and it is opt-in via a `searchable` flag so short pickers — "Databricks
Hosted vs External Models" — keep j/k.

The existing-config warning now says what actually happens. It claimed "there is
no partial update yet", which was both vague and, once `ucode apply` PATCHes, no
longer true. Publishing is a full replace of the workspace's config, so the warning
says that plainly and names the agents already published, which is what the admin
stands to lose. Field masks and PATCH stay out of it — an admin doesn't need to
reason about the transport.

The existing-config warning also says what it is warning about. It named the
enabled agents in a parenthetical — "already exists for this workspace (claude)" —
which read as though the workspace itself were called claude, and listing the
contents implied the config was per-agent when there is exactly one per workspace
covering agents, MCP servers, skills, tracing, and the budget policy. It now says
that scope plainly and itemizes nothing: an inventory doesn't change what the admin
should do, and `ucode setup show` prints the real thing for anyone comparing.

The duplicate "Fetching available models" spinner is gone. `discover_model_services`
and `discover_claude_models_unbucketed` both page the whole metastore catalog, so a
run that configured Claude walked it twice. Rather than widen
`discover_model_services`' 5-tuple return (asserted in 8 tests, mocked in 19, which
is what made this look expensive last time), the memo goes one layer down in
`list_model_services`, where both callers already converge — so no signature
changes and no stub churn. Cached per process and per workspace; failures are never
cached, so a transient error still retries; `use_cache=False` forces a fresh walk.
`conftest` clears it between tests so a cached listing can't leak into a test that
stubs the endpoint.

The Model Provider Service listing gets the same treatment, for the same reason: it
is workspace-wide and filtered per agent afterwards, so the wizard re-listed it once
per MPS-capable agent. One call now serves them all. Cached entries are copied on
both store and read, since the wizard treats the list as its own.

Its spinner is now conditional rather than removed. The cold listing takes ~1.4s, so
silence there would look like a hang; the cached ones are instant, and spinning
"Checking for model provider services for <agent>..." once per agent was what made
the wizard look like it re-listed them every time. The message no longer names an
agent either, since one lookup covers all of them.

Measured against eng-ml-inference.staging: 12 HTTP pages for the model catalog
across both callers, down from 24; and one MPS listing for claude + codex, down from
two.

Tests: +15. Mutation-verified — dropping either cache read fails a cache test,
dropping `searchable` fails the picker test, un-boxing the summary fails the panel
test, and re-adding the config inventory fails the warning test. The MPS aliasing
guard is split in two on purpose: the store-side and read-side copies each need
their own case, since a single test passed with either one still present.

Two more redundant lines removed while in here. The "Next steps" block no longer
suggests `ucode configure --dry-run`: the manifest describes what *developers*
should get, while that command previews *this machine's* agent configs, so it
implied a local test it does not perform. Publishing is the only next step there is
today. And the wizard's own `ensure_databricks_auth` is now quiet, because
`configure_shared_state` authenticates a moment later and prints the same "auth
already available" success — the wizard's call still has to run first, since the
admin gate and the existing-config check both need a token before discovery.

Deferred, with Aarushi's agreement: a root `ucode --dry-run` that launches the
default agent against the authored config, so an admin can try it locally before
publishing. That needs both the flag and bare-`ucode` launch behavior, neither of
which exists yet.

Co-authored-by: Isaac
@tt-le
tt-le force-pushed the tien/managed-setup-wizard branch from 7f8ac63 to 30154f4 Compare August 5, 2026 23:06
tt-le added 3 commits August 6, 2026 14:31
`prompt_for_text`'s default hint was invisible for exactly the values that have
one. Rich parses `[coding-agents-tiered-routing]` as a style tag and renders
nothing for it, so the budget policy-name prompt read `Policy name ›` with no
sign that enter would pick a name. Numeric defaults like `[80]` are not valid tag
names and survived, which is why this held up wherever it was checked.

The hint is now escaped whole, brackets included, and says what enter does:
`Policy name [coding-agents-tiered-routing] (enter to accept)`. Bracketed text on
its own reads as a format example as easily as a value that will be used, so it
invited retyping what enter already picks. Also fixes the skills-location prompt,
the other call site with a word-like default.

`prompt_for_percentage` gets the same formatting so the two cannot drift. No
caller passes it a default: tier thresholds deliberately have none, since a
threshold decides when developers get downgraded and should be typed rather than
accepted by accident. That is now recorded in the docstring.

Tests: +6, rendering through a real Rich console rather than asserting on the raw
markup string — the latter is what let this ship, since `[tiered]` is present in
the markup and absent from the output. Mutation-verified: dropping either
`escape` fails three cases, and the pre-fix assertions pass against the bug.

Co-authored-by: Isaac
`.isaac/config.json` was committed by accident in the review-feedback commit. It
holds one field, `sync_reminder_last_shown` — per-machine state for isaac's own
reminder, not project config — so every developer running isaac would see it as a
spurious modification and could commit their own date over someone else's.

Untracked and added to .gitignore so it stays local. The file itself is left on
disk; only the tracking is removed.

Co-authored-by: Isaac
Two bugs found reviewing #268.

`kv_line` interpolated its value into Rich markup unescaped, and Rich reads
bracketed text as a style tag and renders nothing for it. A budget policy named
`[prod] tiered routing` displayed as ` tiered routing` in the configuration
summary — the one block an admin reads to confirm what they are about to publish
workspace-wide, so a silently altered value defeats its whole purpose. The saved
manifest was always correct; this was display only. Values reaching it include
admin-typed free text: the policy name, skills locations, and the tracing table.

`prompt_for_percentage` re-raised `EOFError` when it had no default to fall back
on, and nothing above it catches that — the setup command handles `RuntimeError`
and `KeyboardInterrupt` only. Pressing Ctrl-D at `Tier 1: activates at what
percent of budget?` (the tier prompt passes no default, deliberately) printed a
traceback instead of exiting cleanly. It now raises `KeyboardInterrupt`, which the
existing handler turns into "Interrupted." and exit 130 — the same thing Ctrl-C
does, which is what closed stdin means here. A default, when one is passed, still
short-circuits first: `prompt_for_text` and `prompt_yes_no_default` already treat
EOF as "take the default", and this was the odd one out only because it can have
no default.

Tests: +3. Mutation-verified: un-escaping `kv_line` fails the summary test, and
re-raising the bare `EOFError` fails the abort test.

Co-authored-by: Isaac
Comment thread src/ucode/ui.py
instruction=instruction,
use_search_filter=searchable,
use_jk_keys=not searchable,
).ask()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This loops forever on Ctrl-C. The docstring says "questionary raises KeyboardInterrupt on Ctrl-C before
returning, so a None here means the picker was dismissed" — but questionary's Question.ask() catches
KeyboardInterrupt internally and returns None (v2.1.1, question.py). So Ctrl-C → None → print_err →
re-prompt, forever. Repros:

ERROR Please choose one of the options.
ERROR Please choose one of the options. # x N

_require_multi_selection right below it gets this right (if picked is None: raise KeyboardInterrupt) —
_require_selection needs the same.

Comment thread src/ucode/ui.py
Comment on lines +440 to +441
if not raw_value and default is not None:
return default

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same infinite loop on closed stdin. prompt_for_text returns default on EOFError, and here default is None, so the while True never exits — a non-interactive run (piped stdin, CI) spins printing "Please enter a model
id." forever. Reachable whenever discovery finds nothing: _prompt_models_for_agent:281,
_prompt_claude_models:342, and the provider-service free-text path at :272.

prompt_for_percentage already solved exactly this — it raises KeyboardInterrupt on EOF with no default, with a good comment about why. Could _require_text do the same, or prompt_for_text grow a required=True?

return model_config


def _claude_candidates(state: dict) -> dict[str, list[str]]:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The all_claude_models cache is what lets validate_manifest accept the older versions these prompts offer, and it's only set when the unbucketed listing succeeds (:412). On the fallback path (discover_claude_models_unbucketed raises, or returns empty) it isn't set — which is fine today, because the fallback also narrows the prompts to claude_models (newest per family) so nothing unknown can be picked. But the coupling is invisible and one-directional: anything that later widens the fallback candidates without setting the cache turns into a confusing end-of-flow rejection:

claude: model 'system.ai.claude-opus-4-8' is not available on this workspace.

A comment tying the two together, or having claude_family_candidates be the single source both read, would keep it from drifting.

Review feedback on #268. Two infinite loops, both confirmed by driving the
helpers directly.

`_require_selection` re-asked on a None answer, and its docstring justified that
by claiming questionary raises KeyboardInterrupt on Ctrl-C before returning. It
does not: `Question.ask` catches KeyboardInterrupt internally and returns None
(questionary 2.1.1, question.py), so Ctrl-C and an empty submission are the same
value and nothing propagates for the caller to distinguish. Ctrl-C therefore
printed "Please choose one of the options." and re-prompted, forever. A None is
now an abort, which `_require_multi_selection` right below it already did.

`_require_text` had the same shape on closed stdin: `prompt_for_text` returns its
default on EOF, the default is None, and the loop re-asked an exhausted stream —
so a piped or CI run spun printing "Please enter a model id." It now passes
`required=True`, a new `prompt_for_text` flag that raises KeyboardInterrupt on EOF
instead of returning None, matching what `prompt_for_percentage` does when it has
no default to fall back on. Reachable whenever model discovery finds nothing,
which is exactly when a run is most likely to be scripted.

Also documents the `all_claude_models` invariant that made the two Claude
candidate paths safe for non-obvious reasons: the listing path widens the
candidates and caches them, while the fallback narrows them to what
`_known_models` already covers. Widening the fallback without caching would
surface as a rejection at the end of the flow rather than at the prompt that
offered the model, so both paths are now pinned by tests that validate everything
they offer.

Tests: +5. `test_empty_single_select_is_re_prompted` asserted the old looping
behavior and is replaced.

Co-authored-by: Isaac
@tt-le
tt-le merged commit 5d1cb00 into tien/managed-setup-serializer Aug 6, 2026
1 of 2 checks passed
@tt-le
tt-le deleted the tien/managed-setup-wizard branch August 6, 2026 19:13
tt-le added a commit that referenced this pull request Aug 6, 2026
* Add `ucode setup`: interactive managed-config authoring

The admin-facing wizard on top of the serializer added in the parent commit.
Walks a workspace admin through agents, per-agent models, tracing, MCP servers,
skills, and a spend-routing budget policy, then writes the manifest to
`~/.ucode/managed-settings.json`. Publishing is a separate `ucode apply` (next
change) so an admin can review the file first — and `ucode setup show` prints both
the summary and the exact proto-JSON that apply will POST.

`managed_wizard.py` is the interaction layer only; catalogs, validation, and
serialization stay in `managed_setup.py`. Sub-flows an admin already knows are
delegated to the existing commands (`configure_tracing_command`,
`configure_mcp_command`, `configure_skills_mcp_command`) and their results read back
out of `state.json`, so there is still exactly one picker per concern.

Claude Code is prompted per model family, not as a flat list. `ClaudeModelConfig.models`
is a `ClaudeDefaultModels` message — four optional slots (`default_opus_model` /
`default_sonnet_model` / `default_haiku_model` / `default_fable_model`), since Claude
Code addresses models by family alias rather than from a list, and a flat multi-select
cannot express that. Each family is skippable (the slots are `optional`, and an unset
one falls back to `default_model`); the overall `default_model` is then chosen from the
slots that were filled, so it can never name a model the config doesn't carry. With one
slot filled there is nothing to choose, so it reports what it inferred rather than
asking a single-option question.

That required looking past `state["claude_models"]`, which is `{family: newest}` because
the launch path pins one model per alias — the workspace tested against has 12 Claude
models and the bucketed view shows 4. `discover_claude_models_unbucketed` returns the
full `system.ai.claude-*` listing, `claude_family_candidates` groups it by family, and
`_known_models` reads the same set so validation cannot reject a version the prompts
offered. `discover_model_services` is untouched — every agent's config writer depends on
the shape of `claude_models`.

Provider-service agents get a real picker too. `list_model_provider_services` already
returns each service's `config.targets` (the provider-side model ids); the wizard now
selects the whole service dict and offers those, instead of an empty text prompt that
required knowing a provider-side id by heart. Two cases legitimately have nothing to
enumerate — `allow_all_targets`, and a relayed Anthropic subscription service that
routes by canonical name — and those fall back to free text, but say why and give an
example.

Gemini and Copilot get one model rather than a multi-select: their writers take a single
model and set one env var, so extra picks were silently discarded at apply time. This is
a wizard restriction, not a serializer one — both protos do declare `repeated string
models`, so `_FLAT_MODEL_LIST_AGENTS` is unchanged and a hand-written `--from-file`
manifest still serializes a list. OpenCode and Pi keep their multi-select; they really
do show a model picker.

Nothing is pre-checked in the model multi-selects. `options[0]` is whatever discovery
sorted first, not a recommendation — for Pi a Claude model, for Codex the oldest GPT —
so pre-checking it made "hit Enter" produce an arbitrary config and read as an
endorsement. It only existed to make Enter work at all, which stopped mattering once
every agent required a model: an empty selection is re-prompted
(`_require_selection` / `_require_multi_selection` / `_require_text`), which also makes
`_prompt_models_for_agent` total. Without that, picking a multi-model agent and hitting
Enter produced an agent with no `model_config`, and if it was the `default_agent` the run
died at the *end* — after tracing, MCP, skills, and budget had all been answered. Ctrl-C
still aborts.

Budget tiers offer only the models their agent was configured with, via
`configured_models_for_agent`. The picker previously read the workspace catalog and
called `load_state()`, so it could not have seen this run's choices even in principle —
configuring Pi with one Kimi model still offered every family. Picking one of the extras
was not harmless: `validateStoredConfig` checks only that a tier's `default_agent` is in
`enabled_agents`, never that the agent has the model, so the tier would activate and hand
a developer a model their agent was never given. `validate_manifest` gains that missing
rule, which is the durable half — it guards `--from-file` and hand-written manifests too.
Tiers are prompted in percent and stored as fractions (`prompt_for_percentage` converts),
keeping the spec-vs-API units mismatch in one place.

Model ids stay bare (`system.ai.claude-opus-4-8`), not provider-prefixed: each agent's
writer adds whatever its config format needs (see `opencode._resolve_model_selector`), so
the manifest stays agent-neutral and matches the spec's examples.

Two readback details worth noting:
- Tracing is stored as `tracing.uc_destination` but the managed config calls it
  `tracing.table`; an enabled-but-not-UC-backed experiment has no table, so it is
  omitted rather than published empty.
- `state.json` records each MCP server's resolved URL but not its type, while the
  manifest needs `{name, type}`. `_mcp_type_for_url` maps the URL shape back to a type
  and skips anything unrecognized instead of guessing.

`databricks.py`: `is_workspace_admin` (SCIM `Me` group membership) and
`list_workspace_budgets` (workspace-scoped, so no account auth is needed — ucode never
creates budgets, an admin picks an existing one). The admin gate errors on a definite
non-admin and warns-then-continues when the check itself fails, since the API enforces
the same rule server-side.

`ui.py`: `preselected`/`prompt` on `prompt_for_tools`, plus `prompt_for_multi_selection`,
`prompt_for_text`, and `prompt_for_percentage`. Reuses the existing
`prompt_yes_no_default` rather than adding a second defaulting yes/no.

README gains a "Managed config for a workspace (admins)" section, `setup` rows in Other
Commands, and `~/.ucode/managed-settings.json` in Managed Local Files. It states plainly
that the wizard leaves your agent configs alone *except* when you accept the tracing /
MCP / skills sub-steps, which do configure this machine — the one thing about the flow
that isn't obvious.

Verified interactively against eng-ml-inference.staging: six opus versions and four
sonnet offered, a skipped family absent from `models`, and the result round-trips through
`normalize_managed_config` to the proto shape.

Tests: 116 cases. Several are mutation-checked — reverting the claude flat multi-select
fails eight, reverting the budget-tier picker fails
`test_offers_only_the_models_the_agent_was_configured_with`, and dropping the tier-model
validation rule fails `test_tier_model_must_be_one_the_agent_has`. Note `typer.Exit`
subclasses RuntimeError, so raising it inside the command's try block made a successful
run print "ERROR 0"; fixed by exiting after the handler, with
`test_successful_setup_exits_zero` failing if that regresses.

Known gap: `discover_model_services` and `discover_claude_models_unbucketed` both page
`/api/2.1/unity-catalog/model-services`, so a run that configures Claude lists it twice.
`_claude_candidates` caches on `state["all_claude_models"]`, which bounds it to once per
run, but removing the second listing means widening `discover_model_services`' 5-tuple
return — asserted in eight tests and mocked in sixteen — so it is left for a follow-up.

Co-authored-by: Isaac

* setup: don't require the databricks CLI to build the Claude catalog

Two CI failures, both passing locally for environment reasons.

`_claude_candidates` guarded its catalog fetch with `except RuntimeError`, but
`get_databricks_token` shells out to `databricks auth token` — so a machine
without the CLI on PATH raises `FileNotFoundError`, which is an `OSError` and
sailed straight through. This is a real bug, not just a test artifact: any user
without the Databricks CLI installed hit an uncaught traceback mid-wizard
instead of degrading to the bucketed per-family picks. Now catches `OSError`
too. `test_claude_candidates_survive_a_missing_databricks_cli` covers it, and
`test_every_agent_always_gets_a_default_model` — which reached the network on
its claude pass — now stubs the fetch rather than depending on the host.

`test_setup_help_lists_from_file` grepped `--from-file` out of rendered `--help`
output. Rich ellipsizes option names to fit the terminal, so below roughly 40
columns it renders `--fro…`; hosted runners report no width and get the narrow
fallback. It now asserts on the declared Click option, which no amount of
wrapping can change, and still checks the help renders successfully.

Verified by reverting each fix in turn: the first fails with the same
`FileNotFoundError: 'databricks'` CI reported, the second with the same missing
`--from-file` assertion at `COLUMNS=30`.

Co-authored-by: Isaac

* setup: box the summary, filter long pickers, and page the catalog once

Review feedback on #268.

The configuration summary is boxed. It is the one block an admin is meant to read
as a whole and check against what they intended, and it arrives after a long flow
of prompts, where loose key/value lines blend into everything printed before them.
`ui.print_panel` boxes a body (as opposed to `print_section`, which boxes a bare
title) and `ui.kv_line` returns a `print_kv`-styled line for collecting into one.

Every picker in the flow now filters as you type. On the workspace this was built
against, the model lists run to 16 GPT entries and 12 Claude ones, which is more
than is comfortable to arrow through. questionary supports this directly, with one
constraint worth naming: it refuses `use_search_filter` together with `use_jk_keys`
(j and k are search characters), so search costs j/k navigation. Arrow keys still
work, and it is opt-in via a `searchable` flag so short pickers — "Databricks
Hosted vs External Models" — keep j/k.

The existing-config warning now says what actually happens. It claimed "there is
no partial update yet", which was both vague and, once `ucode apply` PATCHes, no
longer true. Publishing is a full replace of the workspace's config, so the warning
says that plainly and names the agents already published, which is what the admin
stands to lose. Field masks and PATCH stay out of it — an admin doesn't need to
reason about the transport.

The existing-config warning also says what it is warning about. It named the
enabled agents in a parenthetical — "already exists for this workspace (claude)" —
which read as though the workspace itself were called claude, and listing the
contents implied the config was per-agent when there is exactly one per workspace
covering agents, MCP servers, skills, tracing, and the budget policy. It now says
that scope plainly and itemizes nothing: an inventory doesn't change what the admin
should do, and `ucode setup show` prints the real thing for anyone comparing.

The duplicate "Fetching available models" spinner is gone. `discover_model_services`
and `discover_claude_models_unbucketed` both page the whole metastore catalog, so a
run that configured Claude walked it twice. Rather than widen
`discover_model_services`' 5-tuple return (asserted in 8 tests, mocked in 19, which
is what made this look expensive last time), the memo goes one layer down in
`list_model_services`, where both callers already converge — so no signature
changes and no stub churn. Cached per process and per workspace; failures are never
cached, so a transient error still retries; `use_cache=False` forces a fresh walk.
`conftest` clears it between tests so a cached listing can't leak into a test that
stubs the endpoint.

The Model Provider Service listing gets the same treatment, for the same reason: it
is workspace-wide and filtered per agent afterwards, so the wizard re-listed it once
per MPS-capable agent. One call now serves them all. Cached entries are copied on
both store and read, since the wizard treats the list as its own.

Its spinner is now conditional rather than removed. The cold listing takes ~1.4s, so
silence there would look like a hang; the cached ones are instant, and spinning
"Checking for model provider services for <agent>..." once per agent was what made
the wizard look like it re-listed them every time. The message no longer names an
agent either, since one lookup covers all of them.

Measured against eng-ml-inference.staging: 12 HTTP pages for the model catalog
across both callers, down from 24; and one MPS listing for claude + codex, down from
two.

Tests: +15. Mutation-verified — dropping either cache read fails a cache test,
dropping `searchable` fails the picker test, un-boxing the summary fails the panel
test, and re-adding the config inventory fails the warning test. The MPS aliasing
guard is split in two on purpose: the store-side and read-side copies each need
their own case, since a single test passed with either one still present.

Two more redundant lines removed while in here. The "Next steps" block no longer
suggests `ucode configure --dry-run`: the manifest describes what *developers*
should get, while that command previews *this machine's* agent configs, so it
implied a local test it does not perform. Publishing is the only next step there is
today. And the wizard's own `ensure_databricks_auth` is now quiet, because
`configure_shared_state` authenticates a moment later and prints the same "auth
already available" success — the wizard's call still has to run first, since the
admin gate and the existing-config check both need a token before discovery.

Deferred, with Aarushi's agreement: a root `ucode --dry-run` that launches the
default agent against the authored config, so an admin can try it locally before
publishing. That needs both the flag and bare-`ucode` launch behavior, neither of
which exists yet.

Co-authored-by: Isaac

* setup: show the default a prompt will actually use

`prompt_for_text`'s default hint was invisible for exactly the values that have
one. Rich parses `[coding-agents-tiered-routing]` as a style tag and renders
nothing for it, so the budget policy-name prompt read `Policy name ›` with no
sign that enter would pick a name. Numeric defaults like `[80]` are not valid tag
names and survived, which is why this held up wherever it was checked.

The hint is now escaped whole, brackets included, and says what enter does:
`Policy name [coding-agents-tiered-routing] (enter to accept)`. Bracketed text on
its own reads as a format example as easily as a value that will be used, so it
invited retyping what enter already picks. Also fixes the skills-location prompt,
the other call site with a word-like default.

`prompt_for_percentage` gets the same formatting so the two cannot drift. No
caller passes it a default: tier thresholds deliberately have none, since a
threshold decides when developers get downgraded and should be typed rather than
accepted by accident. That is now recorded in the docstring.

Tests: +6, rendering through a real Rich console rather than asserting on the raw
markup string — the latter is what let this ship, since `[tiered]` is present in
the markup and absent from the output. Mutation-verified: dropping either
`escape` fails three cases, and the pre-fix assertions pass against the bug.

Co-authored-by: Isaac

* Stop tracking .isaac/, a local tool's state dir

`.isaac/config.json` was committed by accident in the review-feedback commit. It
holds one field, `sync_reminder_last_shown` — per-machine state for isaac's own
reminder, not project config — so every developer running isaac would see it as a
spurious modification and could commit their own date over someone else's.

Untracked and added to .gitignore so it stays local. The file itself is left on
disk; only the tracking is removed.

Co-authored-by: Isaac

* setup: don't lose bracketed values, or crash on closed stdin

Two bugs found reviewing #268.

`kv_line` interpolated its value into Rich markup unescaped, and Rich reads
bracketed text as a style tag and renders nothing for it. A budget policy named
`[prod] tiered routing` displayed as ` tiered routing` in the configuration
summary — the one block an admin reads to confirm what they are about to publish
workspace-wide, so a silently altered value defeats its whole purpose. The saved
manifest was always correct; this was display only. Values reaching it include
admin-typed free text: the policy name, skills locations, and the tracing table.

`prompt_for_percentage` re-raised `EOFError` when it had no default to fall back
on, and nothing above it catches that — the setup command handles `RuntimeError`
and `KeyboardInterrupt` only. Pressing Ctrl-D at `Tier 1: activates at what
percent of budget?` (the tier prompt passes no default, deliberately) printed a
traceback instead of exiting cleanly. It now raises `KeyboardInterrupt`, which the
existing handler turns into "Interrupted." and exit 130 — the same thing Ctrl-C
does, which is what closed stdin means here. A default, when one is passed, still
short-circuits first: `prompt_for_text` and `prompt_yes_no_default` already treat
EOF as "take the default", and this was the odd one out only because it can have
no default.

Tests: +3. Mutation-verified: un-escaping `kv_line` fails the summary test, and
re-raising the bare `EOFError` fails the abort test.

Co-authored-by: Isaac

* setup: abort dismissed prompts instead of re-asking forever

Review feedback on #268. Two infinite loops, both confirmed by driving the
helpers directly.

`_require_selection` re-asked on a None answer, and its docstring justified that
by claiming questionary raises KeyboardInterrupt on Ctrl-C before returning. It
does not: `Question.ask` catches KeyboardInterrupt internally and returns None
(questionary 2.1.1, question.py), so Ctrl-C and an empty submission are the same
value and nothing propagates for the caller to distinguish. Ctrl-C therefore
printed "Please choose one of the options." and re-prompted, forever. A None is
now an abort, which `_require_multi_selection` right below it already did.

`_require_text` had the same shape on closed stdin: `prompt_for_text` returns its
default on EOF, the default is None, and the loop re-asked an exhausted stream —
so a piped or CI run spun printing "Please enter a model id." It now passes
`required=True`, a new `prompt_for_text` flag that raises KeyboardInterrupt on EOF
instead of returning None, matching what `prompt_for_percentage` does when it has
no default to fall back on. Reachable whenever model discovery finds nothing,
which is exactly when a run is most likely to be scripted.

Also documents the `all_claude_models` invariant that made the two Claude
candidate paths safe for non-obvious reasons: the listing path widens the
candidates and caches them, while the fallback narrows them to what
`_known_models` already covers. Widening the fallback without caching would
surface as a rejection at the end of the flow rather than at the prompt that
offered the model, so both paths are now pinned by tests that validate everything
they offer.

Tests: +5. `test_empty_single_select_is_re_prompted` asserted the old looping
behavior and is replaced.

Co-authored-by: Isaac
tt-le added a commit that referenced this pull request Aug 7, 2026
`ucode apply` rejected a model `ucode setup` had just offered:

    claude: model 'system.ai.claude-opus-4-8' is not available on this workspace.

`state["claude_models"]` holds only the newest id per family, because the launch
path pins one model per family alias. The wizard deliberately offers the older
versions too — pinning `default_opus_model` to a known-good `claude-opus-4-8` is a
normal thing for an admin to want — and stashes the full listing on
`state["all_claude_models"]` so validation recognizes them.

That stash is never persisted. `setup` saves the manifest, not the state, so a
separate `apply` process starts from a fresh `load_state()` without it and
validates against the narrow per-family inventory. The failure lands at the very
end of the flow, naming a model the wizard itself had listed a moment earlier.

`apply` now re-fetches the listing instead of trusting what `setup` left behind,
which also covers a hand-edited or `--from-file` manifest authored on another
machine. Best-effort: a failed listing leaves validation on the narrower inventory
rather than blocking a publish on a transient API error. `ensure_databricks_auth`
moves above validation since the listing needs a token; nothing is written until
well after.

Aarushi predicted this failure, and its exact message, reviewing the invariant on
`_claude_candidates` in #268. The fix there documented and tested the invariant
within one process — both paths satisfied it — and so missed that `setup` and
`apply` are two processes.

Tests: +2, covering the older-version publish and that a failed fetch still
publishes. Mutation-verified: validating against bare `state` reproduces the
original error.

Co-authored-by: Isaac
tt-le added a commit that referenced this pull request Aug 7, 2026
* Add PATCH/DELETE transport and coding-agent-config CRUD clients

The write-side API plumbing for `ucode apply` (next change). No CLI wiring and no
interactive flow: transport helpers plus three clients, mirroring how the read
client landed.

`databricks.py` had `_http_get_json`/`_http_post_json` but no PATCH or DELETE.
Rather than a third and fourth near-copy of the same 40 lines of error handling,
the body-sending path is factored into `_http_send_json(method, ...)` and the
three verbs become thin wrappers. `_http_post_json`'s behavior is unchanged.

DELETE needed one real difference: its success response is
`google.protobuf.Empty`, which arrives as `{}` or an empty body depending on the
gateway. An empty body would otherwise be reported as "response was not valid
JSON", so `allow_empty_body` treats it as success. `delete_coding_agent_config`
returns only a reason — there is no payload worth handing back.

Clients for the three admin RPCs, all workspace-admin gated server-side:
- `create_coding_agent_config` — POST to the collection. v0 allows one config per
  workspace, so this returns ALREADY_EXISTS when one exists.
- `update_coding_agent_config` — PATCH the resource. Preferred over
  delete-then-create, which has a window where the workspace has *no* managed
  config: if the create failed, every developer would lose their config until
  someone re-ran the command. The server applies the mask inside a single
  entity-store update, so a failed write leaves the old config intact.
- `delete_coding_agent_config` — DELETE by resource name.

`MANAGED_CONFIG_UPDATE_MASK_PATHS` is every field ucode's manifest can set. The
server requires a non-empty mask and rejects paths outside its mutable set; this
is that set minus what ucode doesn't author — `budget_id` (deprecated for
`budget_policy.budget_id`, and rejected on write) and `default_options`/`tiers`
(the legacy model-only shape). Sending every path ucode owns, not just the
populated ones, is what lets a re-run *clear* a field the admin removed: the
server merges per path, so an omitted path leaves the old value in place.

`_coding_agent_config_url` joins on the API root rather than the collection URL,
since the resource name already carries the `coding-agent-configs/` segment and
would otherwise be duplicated.

Tests: 15 cases. The mask is checked against `serialize_managed_config`'s actual
output rather than a restated list, so adding a manifest field fails the test
instead of shipping a mask that cannot clear it. Mutation-verified three ways:
dropping `allow_empty_body`, dropping the `update_mask`, and dropping one mask
path each fail a specific test.

Co-authored-by: Isaac

* setup: require a UUID budget id, and index tiers the way the server does

Three gaps found by reading the server-side validation this manifest is written
for (universe #2365441), all reachable through `--from-file` even though the
wizard can't produce them.

`budget_policy.budget_id` must parse as a UUID. The handler requires it, and
until now ucode only checked non-empty — so a hand-written manifest carrying
`"budget_id": "eng-budget"` passed local validation and failed at the API with an
INVALID_PARAMETER_VALUE. Local pre-flight exists precisely to spend the round
trip on real problems. The message names `budget_configuration_id` so an admin
knows where to get a valid one.

Tier positions are now reported 0-based. The server indexes with `zipWithIndex`,
so ucode's `tiers[1]` and the API's `tiers[0]` described the same tier — an admin
reconciling the two messages would be looking at the wrong one.

Added a test that the deprecated top-level `CodingAgentConfig.budget_id` (field 3)
is never emitted, even when a hand-written manifest sets it. The serializer
already only writes `budget_policy.budget_id`; the handler rejects the top-level
field, so this pins behavior that is currently correct by construction rather
than by intent.

Test fixtures used short placeholders (`"b"`, `"budget-1"`) where a real
`budget_configuration_id` would be, so those are now UUIDs — 20 occurrences
across the two files. `list_workspace_budgets` only ever returns real ones, so
the fixtures were describing input the wizard can't produce.

Tests: +6. Mutation-verified: dropping the UUID check fails four cases, reverting
to 1-based indices fails `test_tier_positions_are_reported_zero_based`, and
emitting the top-level `budget_id` fails
`test_a_manifest_carrying_a_top_level_budget_id_still_omits_it`.

Co-authored-by: Isaac

* setup: drop the agent -> oneof-variant identity map

Review feedback on #267: `_AGENT_MODEL_CONFIG_VARIANT` mapped each agent to its
`AgentModelConfig` oneof key, but the proto's field names are ucode's tool names
verbatim — claude, codex, opencode, pi, gemini, copilot — so every entry mapped a
name to itself. One use site, so the tool now serves as the key directly.

The dict's only other effect was a KeyError on an unknown agent, which was already
unreachable: `serialize_managed_config` filters to `tool in AGENT_TOOL_TO_ENUM`
before calling this, and the `AGENT_TOOL_TO_ENUM[tool]` lookup two lines down would
raise first anyway.

No new test. The variant keys are already covered — hard-coding the wrong one fails
`test_codex_model_config_has_no_model_list` and
`test_flat_list_agents_use_repeated_models`, and the round-trip through
`normalize_managed_config` asserts the alignment for every agent.

The other half of that review comment — validating a model against its agent's
dialect, so a manifest can't pin a GPT id for Claude Code — is deliberately not
here. It turned out to need a decision rather than a patch: the agent -> families
mapping already exists twice (`agents._TOOL_DISCOVERY_SOURCES` and
`managed_setup._AGENT_MODEL_FAMILIES`) and the two disagree for codex, copilot,
opencode, and pi. Picking one requires checking each agent's own writer, and the
likely outcome is that this module's opencode entry is wrong — it lists `codex`
while `build_opencode_base_urls` serves no OpenAI route — which would be a picker
bug in the wizard, not a refactor. Landing that separately.

Co-authored-by: Isaac

* Add `ucode apply`: publish the authored managed config (#271)

The publish step for the manifest `ucode setup` authors. Validates, shows what
would change, confirms, then writes it to the workspace via the clients added in
the parent commit.

Updates in place rather than replacing. When the workspace already has a config,
`apply` PATCHes it using its resource name (read back from the existing-config
GET, which `normalize_managed_config` preserves). Delete-then-create was the
original plan — v0's Create returns ALREADY_EXISTS — but it has a window where the
workspace has *no* managed config, and if the create failed there every developer
would silently fall back to their own settings until someone re-ran the command.
The server applies the update mask inside a single entity-store update, so a
failed PATCH leaves the current config intact. It is still a whole-manifest write:
every path ucode owns is sent, so a field the admin dropped on a re-run is cleared
rather than left behind.

Refuses to publish when it cannot tell whether a config already exists. A failed
existence check used to be the one case where "just try the create" would either
duplicate or silently overwrite an admin's work, so an unreadable check is a hard
error naming the reason rather than a warning.

`_explain_publish_failure` maps the failures an admin will actually hit.
FEATURE_DISABLED is the likely first experience — the CRUD flag is off by default —
so it names `codingAgentConfigCrudEnabled` instead of printing an HTTP 400.
INVALID_PARAMETER_VALUE is passed through verbatim: the server names the offending
field, which beats any paraphrase.

`--yes` skips the confirmation for CI; `--dry-run` validates and previews without
writing. The admin gate and validation both run before anything is sent, so an
invalid manifest or a non-admin costs no round trip.

README documents the publish step and drops the "no partial update yet" caveat,
which the PATCH path makes untrue.

Verified against eng-ml-inference.staging: `apply --dry-run` authenticated,
verified admin, rendered the summary, detected the existing config, and chose the
update path without writing.

Tests: 19 cases. Mutation-verified three ways — always-create instead of PATCH,
publishing despite an unreadable existence check, and publishing an invalid
manifest each fail a specific test. Also covers that `typer.Exit(0)` isn't caught
by the command's own RuntimeError handler, the same trap `setup` hit.

Co-authored-by: Isaac

* apply: validate against the workspace's full model listing

`ucode apply` rejected a model `ucode setup` had just offered:

    claude: model 'system.ai.claude-opus-4-8' is not available on this workspace.

`state["claude_models"]` holds only the newest id per family, because the launch
path pins one model per family alias. The wizard deliberately offers the older
versions too — pinning `default_opus_model` to a known-good `claude-opus-4-8` is a
normal thing for an admin to want — and stashes the full listing on
`state["all_claude_models"]` so validation recognizes them.

That stash is never persisted. `setup` saves the manifest, not the state, so a
separate `apply` process starts from a fresh `load_state()` without it and
validates against the narrow per-family inventory. The failure lands at the very
end of the flow, naming a model the wizard itself had listed a moment earlier.

`apply` now re-fetches the listing instead of trusting what `setup` left behind,
which also covers a hand-edited or `--from-file` manifest authored on another
machine. Best-effort: a failed listing leaves validation on the narrower inventory
rather than blocking a publish on a transient API error. `ensure_databricks_auth`
moves above validation since the listing needs a token; nothing is written until
well after.

Aarushi predicted this failure, and its exact message, reviewing the invariant on
`_claude_candidates` in #268. The fix there documented and tested the invariant
within one process — both paths satisfied it — and so missed that `setup` and
`apply` are two processes.

Tests: +2, covering the older-version publish and that a failed fetch still
publishes. Mutation-verified: validating against bare `state` reproduces the
original error.

Co-authored-by: Isaac

* apply: send update_mask as a query param, not in the body

The PATCH was rejected by the server it was written for:

    HTTP 400 INVALID_PARAMETER_VALUE: Field 'update_mask' is required and must
    contain at least one subfield with a non-default value!

Two mistakes, both visible in the RPC's HTTP binding (universe
ai-gateway-api/api/proto/service.proto:319):

    patch: "/ai-gateway/v2/{coding_agent_config.name=coding-agent-configs/*}"
    body: "coding_agent_config"

`body: "coding_agent_config"` means the config *is* the entire request body, so
there is nowhere in it for a sibling `update_mask` — the one we nested was parsed
as an unknown config field, leaving the mask genuinely absent. It belongs in the
query string.

And `update_mask` is a `google.protobuf.FieldMask`, whose JSON and query form is a
single comma-separated string, not the `{"paths": [...]}` object we sent.

`name` stays in the body: the path template reads it from the config.

The mask contents were right, and the 15 tests covering them all passed — they
asserted on `payload["update_mask"]["paths"]`, which is exactly the shape the
server rejects. A unit test that mirrors the client's own assumption cannot catch
a wire-format error; this was only ever going to surface against a real workspace.

Tests: the update test now asserts the mask arrives in the query string, as one
comma-separated FieldMask, and is absent from the body. Mutation-verified:
restoring the nested-object form fails it.

Co-authored-by: Isaac
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants