pyRPC
← Back to Blog

No pyrpc init needed: designing the integrated setup wizard

·8 min read

Many CLI tools have a separate init or setup command. You run pyrpc init, answer a few questions, and it creates a config file. Then you run pyrpc dev to start working. Two commands, one configuration step, one dev step.

pyrpc doesn’t have an init command. The setup wizard is embedded inside pyrpc dev. This was a deliberate design decision with three motivations.

Motivation 1: Reduce context-switching

Every command the user runs is a context switch. “Which command do I run first?” is a question that every new-user tutorial has to answer. When the setup and the dev server are the same command, the tutorial reduces to one step:

# Two commands (traditional approach):
pyrpc init           # "What framework? What module? Where's the client?"
pyrpc dev            # Start the server

# One command (integrated approach):
pyrpc dev            # "First run? Let's set up. Also, server's starting."

The integrated approach is what npm create, npx create-react-app, and uv init do: they combine scaffolding with first-run experience. pyrpc takes the same philosophy: the first invocation of pyrpc devdetects the missing config, walks through setup, writes the config, starts the server, and generates types — all in one command.

Motivation 2: The wizard is always accessible via --reconfigure

If setup and dev are separate commands, what happens when the user needs to change their configuration later? Do they run pyrpc init again? Does it overwrite the existing config? Do they need a separatepyrpc reconfigure command?

With the integrated approach, the --reconfigure flag re-runs the exact same wizard, pre-filled with the current config values as defaults:

pyrpc dev --reconfigure

# Previously entered values appear as defaults:
# ? Which web framework?  (fastapi)
# ? Python module:  (app.main)
# ? TypeScript client path:  (../frontend)

The previous parameter flows through the wizard pipeline:

def _prompt_for_config(previous=None):
    default_framework = (previous or {}).get("framework", "fastapi")
    default_entry = (previous or {}).get("entrypoint", "main")
    default_client = (previous or {}).get("client_root", "")
    
    framework = questionary.select(..., default=default_framework).ask()
    entry = questionary.text(..., default=default_entry).ask()
    client_root = questionary.text(..., default=default_client).ask()
    
    return {"framework": framework, "entrypoint": entry, "client_root": client_root}

This means --reconfigure is never destructive. The user enters the wizard, sees their current values, and changes only what they need. If they cancel (Ctrl+C or Escape at any prompt), the config file is untouched.

Motivation 3: CLI flags can skip the wizard entirely

What about users who don’t want an interactive wizard at all? CI scripts, Dockerfiles, experienced users who know their config values? The integrated design also accepts CLI flags that skip the wizard:

# No wizard, no prompts, no TTY required:
pyrpc dev --framework fastapi --entry main --client-root ../frontend

# Or update just one value without re-answering everything:
pyrpc dev --client-root ../new-client

The flag-based path writes the config file directly (including a version field) without ever calling questionary. This is how CI scripts will eventually set up pyrpc — fast, deterministic, non-interactive.

What the wizard asks

The wizard has exactly three questions, all required:

? Which web framework are you using?
  > fastapi
    flask
    asgi

? Python module to scan for @rpc procedures (e.g. main, app.main)
  main_

? Where is your TypeScript client project? (relative path, e.g. ../frontend)
  ../frontend_

Three questions was the minimum viable set. We considered makingclient_root optional (for Python-only projects), but that created a fourth “do you have a TypeScript client?” question which was harder to explain. Three simple required fields, consistently ordered: framework (the “how”), entrypoint (the “what”), client_root (the “where”).

We also considered a “skip” option for each question. But skips create ambiguity: what does skipping client_root mean? We don’t generate types? We generate them somewhere default? Every skipped value is a question we’ll have to ask again later. Three required fields, all at once, is simpler.

KeyboardInterrupt: the silent cancel

Every questionary.ask() call can return None. This happens when the user presses Ctrl+C, Escape, or when stdin is closed. pyrpc checks every return value:

framework = questionary.select(...).ask()
if framework is None:
    return None  # propagates up to _ensure_config → Exit(code=0)

The None propagation goes through _ensure_config:

def _ensure_config(...):
    if not reconfigure:
        config = _read_pyrpc_config()
        if config:
            return config  # existing config → skip wizard
    
    config = _prompt_for_config(...)
    if config is None:
        return None  # user cancelled
    
    _write_pyrpc_config(config)  # only called if config is non-None
    return config

And finally to the dev command:

if cfg is None:
    console.print("[yellow]Setup cancelled.[/yellow]")
    raise typer.Exit(code=0)  # clean exit, not an error

The result: no stack traces, no half-written config files, no confusing state. Just a clean “Setup cancelled” message and exit code 0 (not an error — cancellation is a normal user action).

Design principle: detect, don't require

The overarching principle is “detect, don’t require.”pyrpc dev detects whether a config file exists. If it does, it uses it. If it doesn’t, it prompts. If flags are provided, it uses them. The wizard is a fallback, not a prerequisite — it fires exactly when the tool can’t proceed without information it doesn’t have. This is the same principle that git commit follows (opens editor only if no -m flag), and that apt install follows (prompts for confirmation only in interactive terminals).