← Back to Blog

Manual entry as a first-class wizard action

·7 min read

v0.11.1 contains a small change with a large consequence for the first-run setup wizard. When pyrpc dev detects more than one frontend project in your repository, it asks how you want to configure clients. In v0.11.0 that question mixed theEnter a client path manually escape hatch into the same checkbox list as the detected projects, a design that could silently throw away your checked selections. v0.11.1 makes manual entry a first-class action, chosen before the checkbox ever appears, so your selections can never be discarded.

The multi-project branch of the wizard

The whole flow lives in _run_wizard inpyrpc_core/cli.py. After you type the entry module, the wizard calls _find_frontend_projects(root), which walks the directory tree looking for known framework config files and skips node_modules, __pycache__,.venv, venv, env,dist, build, .git,.next, and any dot-directory. The result is a list of(path, framework) pairs, for example("./frontend", "Next.js") and("./admin", "Vite").

Three outcomes are possible. If the list is empty, you get a plain text prompt for the client root plus a frameworkquestionary.select. If it contains exactly one project, the text prompt is pre-filled with that path and the framework select is pre-set to the detected framework, the wizard does the work for you. The interesting case, and the one v0.11.1 fixes, is when multiple projects are detected. First the wizard prints what it found:

# _run_wizard, multi-project branch (v0.11.1)
console.print("
[bold]Detected frontend projects:[/bold]")
for path, fw in detected_projects:
 console.print(f" • [cyan]{path}[/cyan] [dim]({fw})[/dim]")

action = questionary.select(
 "How would you like to configure clients?",
 choices=["Select detected projects", "Enter a client path manually"],
).ask()
if action is None:
 raise typer.Exit(code=0)

The two choices are mutually exclusive modes, not selections. Picking Enter a client path manually skips the checkbox entirely and returns a single-client config:

if action == "Enter a client path manually":
 client = questionary.text("Client project root", default=".").ask()
 if client is None:
 raise typer.Exit(code=0)
 framework = questionary.select(
 "Frontend framework", choices=_FRAMEWORK_LABELS, default="Next.js"
 ).ask()
 if framework is None:
 raise typer.Exit(code=0)
 return {"module": module, "framework": framework, "client": client}

choices = [f"{path} ({fw})" for path, fw in detected_projects]
while True:
 selections = questionary.checkbox("Select detected projects", choices=choices).ask()
 if selections is None:
 raise typer.Exit(code=0)
 if selections:
 break
 console.print("[yellow]No projects selected, choose at least one or press Ctrl+C to cancel.[/yellow]")

Note the two config shapes. Manual entry returns a"client" key (a single path); project selection returns a "clients" key (a list of paths). The rest of the CLI never needs to know which path you took:_get_clients(cfg) normalizes both forms into a list, so dev, watch, and the codegen path all consume the same structure.

How checkbox labels become client paths

The checkbox choices are display strings, each detected project rendered as path (framework), e.g../frontend (Next.js). Questionary hands back the strings the user checked, so the wizard has to reverse-map them to the bare paths before they reach the config:

clients = []
for sel in selections:
 for p, f in detected_projects:
 if sel == f"{p} ({f})":
 clients.append(p)
 break
return {"module": module, "framework": "Mixed", "clients": clients}

This mapping is a good example of why the checkbox now containsonly detected projects: the reverse lookup works because every choice is a real (path, framework) pair. If a non-path label had slipped in as a choice, it would either fail to match anything (and vanish silently) or worse, be appended toclients verbatim as garbage. Both were failure modes of the v0.11.0 design. Note also the framework value: when you pick several projects, no single framework label fits, so the wizard records "Mixed" and the per-client framework that matters, Next.js, Vite, and so on, lives on each client’s own side of the tree.

What changed in v0.11.1

In v0.11.0, manual entry was offered as an item inside the checkbox list, right next to the detected projects. The failure mode is the classic one for mixing a meta-action into a data list: the two cannot be reconciled. Choose manual entry and your detected-project selections are thrown away; choose it alongside the projects and the config is handed a value that is not a real path. Either way, the wizard silently ignored part of what you told it, the exact kind of quiet data loss that setup flows must never have.

v0.11.1 splits the concern. A questionary.select asks the mode question first. The checkbox that follows only ever contains detected projects, one per line, so every checked value is guaranteed to be a real client root.

A select is a mode, a checkbox is a data set

The change encodes an interaction-design principle worth stating explicitly: don’t let a secondary action destroy primary selections. A questionary.select is a mutually exclusive choice between modes, “how should I configure clients?” A questionary.checkbox is a multi-select of values within a single mode, “which of these detected projects?” Putting a mode switch inside a value list blurs that boundary.

When a control is simultaneously a value and a command, the user cannot express “both”, so the tool must pick one meaning and the other is lost. The user’s mental model is that a checkbox collects things; discovering that one of the items was actually a redirect, and that checking it erased the other selections, is exactly the kind of surprise that erodes trust in a setup tool. By promoting manual entry to amode, the two concerns become orthogonal: you first decidehow to configure clients, then you supply the data for that mode. A mode can never erase data you haven’t chosen yet, and selections made in one mode never leak into the other.

The principle generalizes well beyond wizards. Toolbars that mix commands into a list of items, menus where a menu item doubles as a setting toggle, config forms where a checkbox both enables a feature and selects its sub-options, all share the same structural flaw: one control, two incompatible meanings. The rule of thumb is to keep modes (exclusive, whole-flow decisions) in a select or segmented control, and to keepvalues (additive, per-item decisions) in a checkbox. Anything that changes what the rest of the form means is a mode, and modes should be decided before values are collected, never inside the value collector.

The empty-selection loop

The other half of the fix guards the checkbox itself.questionary.checkbox returns an empty list when you confirm with nothing checked, and all options start unchecked by default. In v0.11.0, an empty selection flowed straight into the config as "clients": [], anddev later printed ○ no clients configured , skipping type generation: no error, no retry, just a quietly empty configuration. The wizard had asked a question, the user had (accidentally) answered “none”, and the tool accepted it.

Now the checkbox lives in a while True loop. A confirmed empty selection re-asks instead of proceeding, printing the yellow hint No projects selected, choose at least one or press Ctrl+C to cancel. The message is deliberately actionable: it tells you both how to fix the situation and how to escape it. The only ways out of the loop are a non-empty selection or cancellation.

There is a subtle reason this retry lives in the wizard and nowhere else. A non-interactive command like pyrpc dev --yes or a CI job must never block on a loop, it either succeeds or fails loudly. But the wizard is the one place where a human is actively looking at the screen, so a retry costs nothing and a silent empty config costs a whole debugging session later. The loop is the interactive counterpart to the non-interactive failure: the wizard refuses to produce a config that means “nothing”, just as --yesrefuses to guess when it can’t detect a unique client.

Ctrl+C is always the escape

Every questionary prompt in the wizard returnsNone when cancelled with Ctrl+C, and every call is checked immediately: if selections is None: raise typer.Exit(code=0). Cancellation is a first-class exit path everywhere, never a crash, never a swallowedKeyboardInterrupt. The re-prompt loop is deliberately the only place that behaves differently: a cancelled prompt still aborts cleanly, while an empty-but-confirmed selection is re-asked. That distinction matters. Ctrl+C is unambiguous user intent (“stop”); pressing Enter on an empty checkbox is almost always an accident (“I meant to pick something”). Only the latter deserves a second chance.

What the fix protects

The setup wizard is the first thing a new user touches, and it runs exactly once, the resulting pyrpc.jsondrives every later command. A configuration error here is a configuration error everywhere, silently: the wrong client list means the wrong __pyrpc.d.ts files get written, or none at all. The deeper lesson is that in a setup flow, every prompt is a chance to lose user data. Selections are the primary thing a user produces; escape hatches (manual entry, cancellation) are secondary. Secondary actions should never be able to erase primary selections.

v0.11.1 also ships regression tests covering the manual-entry branch of the wizard, so the split stays split: manual entry as a separate action, checkbox selections as pure data, and an empty-selection loop that refuses to produce a"clients": [] config. If you’re curious how the rest of the multi-client config flows through the system, thepyrpc.json lifecycle posttraces it end to end, and thequickstartshows the wizard from the outside.

Read the full changelogfor the complete list of changes.