Some commands benefit from a conversation. Creating a new project, configuring credentials for the first time, choosing which environments to deploy to — tasks done rarely, with several decisions, by people who do not remember the flags. Typing a template name correctly from memory is harder than picking it from an arrow-key menu; ticking three features in a checkbox list is easier than recalling --feature spelled three times. Typer and Click provide simple text prompts and confirmations; for menus, checkboxes and autocompletion, questionary (built on prompt_toolkit) is the usual choice. This guide builds a guided new command that asks only for what flags did not supply, validates as the user types, confirms a summary, prints the equivalent command for next time — and never prompts where nobody can answer. It belongs to the interactive terminal UI with Rich topic.
Prerequisites
- Python 3.10+, Typer and
questionary2.x (uv add questionary). - A command whose inputs can all be given as flags — prompts are a layer on top, never a replacement, as argued in choosing between a CLI, a prompt flow and a TUI.
Choosing the right kind of question
Match the question to the answer. Text for free-form values such as names, always with validation. Confirm for a yes/no before doing something. Select — an arrow-key menu — for one choice from a short list, which removes typos entirely. Checkbox for several choices. Autocomplete for long lists where typing a few characters narrows the options. Typer's typer.prompt and typer.confirm cover the first two with no extra dependency; questionary adds the rest.
The recipe: prompts that fill gaps
Keep the prompt logic separate from both the command and the prompt library. The flow is a function that receives an Asker — anything with text, select, checkbox and confirm methods — so production code passes a questionary-backed implementation and tests pass a fake:
# src/mytool/wizard.py
from __future__ import annotations
import re
import shlex
from dataclasses import dataclass
from typing import Protocol
TEMPLATES = {"basic": "A minimal CLI", "service": "A CLI with an HTTP client", "data": "A data pipeline CLI"}
FEATURES = {"docker": "Dockerfile", "ci": "GitHub Actions workflow", "docs": "MkDocs site"}
NAME_RE = re.compile(r"^[a-z][a-z0-9-]{1,30}$")
class Asker(Protocol):
def text(self, message: str, validate) -> str: ...
def select(self, message: str, choices: dict[str, str], default: str) -> str: ...
def checkbox(self, message: str, choices: dict[str, str]) -> list[str]: ...
def confirm(self, message: str) -> bool: ...
@dataclass
class Answers:
name: str | None = None
template: str | None = None
features: list[str] | None = None
def command(self) -> str:
parts = ["mytool", "new", "--name", self.name or "", "--template", self.template or ""]
for f in self.features or []:
parts += ["--feature", f]
return shlex.join(parts)
def validate_name(value: str) -> bool | str:
return True if NAME_RE.fullmatch(value) else "use 2-31 lowercase letters, digits and dashes"
def fill_gaps(given: Answers, ask: Asker) -> Answers | None:
"""Ask only for what flags did not provide; None if the user declines at the end."""
name = given.name or ask.text("Project name:", validate=validate_name)
template = given.template or ask.select("Template:", TEMPLATES, default="basic")
features = given.features if given.features is not None else ask.checkbox("Extras:", FEATURES)
answers = Answers(name, template, features)
summary = f"Create {name!r} from the {template!r} template with {', '.join(features) or 'no extras'}?"
return answers if ask.confirm(summary) else None
class QuestionaryAsker:
"""The real implementation. Imported lazily so non-interactive runs never load it."""
def text(self, message, validate):
import questionary
return questionary.text(message, validate=validate).unsafe_ask()
def select(self, message, choices, default):
import questionary
opts = [questionary.Choice(f"{k:<8} {v}", value=k) for k, v in choices.items()]
return questionary.select(message, choices=opts, default=next(o for o in opts if o.value == default)).unsafe_ask()
def checkbox(self, message, choices):
import questionary
return questionary.checkbox(message, choices=[questionary.Choice(v, value=k) for k, v in choices.items()]).unsafe_ask()
def confirm(self, message):
import questionary
return questionary.confirm(message, default=True).unsafe_ask()
# src/mytool/cli.py
from __future__ import annotations
import sys
from typing import Annotated
import typer
from mytool.wizard import FEATURES, TEMPLATES, Answers, QuestionaryAsker, fill_gaps
app = typer.Typer()
def interactive() -> bool:
return sys.stdin.isatty() and sys.stdout.isatty()
@app.callback()
def main() -> None:
"""Project scaffolding."""
@app.command()
def new(
name: Annotated[str | None, typer.Option(help="Project name.")] = None,
template: Annotated[str | None, typer.Option(help=f"One of: {', '.join(TEMPLATES)}.")] = None,
feature: Annotated[list[str] | None, typer.Option(help=f"Extras: {', '.join(FEATURES)} (repeatable).")] = None,
no_input: Annotated[bool, typer.Option("--no-input", help="Never prompt.")] = False,
) -> None:
"""Create a project; prompts for anything not given as a flag."""
given = Answers(name, template, feature)
complete = name is not None and template is not None
if not complete and (no_input or not interactive()):
typer.echo("error: --name and --template are required when not running interactively", err=True)
raise typer.Exit(2)
if complete:
answers = Answers(name, template, feature or [])
else:
try:
answers = fill_gaps(given, QuestionaryAsker())
except KeyboardInterrupt:
typer.echo("\ncancelled", err=True)
raise typer.Exit(130)
if answers is None:
typer.echo("nothing created", err=True)
raise typer.Exit(1)
typer.echo(f"next time: {answers.command()}", err=True)
typer.echo(f"created {answers.name} ({answers.template}; {', '.join(answers.features) or 'no extras'})")
if __name__ == "__main__":
app()
What the design gives you
Flag parity. Every question corresponds to a flag. mytool new --name billing --template service --feature ci runs without a single prompt, so documentation, scripts and CI use the same command a newcomer uses interactively.
Prompts fill gaps only. fill_gaps asks only for values the flags did not provide. Someone who remembers the template but not the extras types mytool new --template service and is asked two questions, not four.
Validation while typing. questionary runs the validate callback on each keystroke and shows the message inline, so an invalid name never gets as far as the command. The same validate_name function is used for flags, keeping the rules identical.
A summary before acting. The final confirmation restates what will happen, the moment to catch a wrong template before files are created.
The equivalent command, printed at the end. "next time: mytool new --name billing --template service --feature ci" teaches the fast path from the slow one, and gives users something to paste into docs or scripts. shlex.join quotes it correctly for the shell.
Lazy import. questionary and prompt_toolkit are imported inside QuestionaryAsker's methods, so commands run from scripts never pay their import cost — consistent with lazy-loading subcommands for faster startup.
unsafe_ask() and Ctrl+C. questionary's ask() swallows Ctrl+C and returns None; unsafe_ask() raises KeyboardInterrupt, which the command catches to print "cancelled" and exit 130 — the convention from handling KeyboardInterrupt cleanly. Returning None from a prompt silently is how half-configured projects get created.
UX considerations
- Never prompt without a terminal. The command checks that stdin and stdout are TTYs and that
--no-inputwas not given; otherwise it fails immediately, naming the missing flags. A prompt in CI hangs until the job times out. Detecting CI environments and non-interactive shells covers the detection in detail. - Sensible defaults. Pre-select the most common option (
default="basic") so Enter does the usual thing. - Descriptions in menus. "service A CLI with an HTTP client" is more useful than "service" alone; menus are the one place where a short description costs nothing.
- Keep flows short. Three to five questions is comfortable; beyond that, write the answers to a config file the user can edit, rather than asking every time.
- Prompts go to the terminal, not stdout. questionary writes to the terminal directly, so a command's stdout remains clean data — the final
created ...line here — even after an interactive session.
Testing the behaviour
Because the flow depends on an Asker protocol, tests exercise it with a fake that records which questions were asked:
# tests/test_wizard.py
from typer.testing import CliRunner
from mytool.cli import app
from mytool.wizard import Answers, fill_gaps, validate_name
runner = CliRunner()
class FakeAsker:
def __init__(self, **answers):
self.answers = answers
self.asked: list[str] = []
def text(self, message, validate):
self.asked.append("name")
value = self.answers["name"]
assert validate(value) is True
return value
def select(self, message, choices, default):
self.asked.append("template")
return self.answers["template"]
def checkbox(self, message, choices):
self.asked.append("features")
return self.answers["features"]
def confirm(self, message):
return self.answers.get("confirm", True)
def test_only_missing_values_are_asked():
ask = FakeAsker(template="service", features=["ci"])
result = fill_gaps(Answers(name="billing"), ask)
assert ask.asked == ["template", "features"]
assert result.command() == "mytool new --name billing --template service --feature ci"
def test_declining_the_summary_returns_none():
assert fill_gaps(Answers("a1", "basic", []), FakeAsker(confirm=False)) is None
def test_name_validation():
assert validate_name("billing-api") is True
assert "lowercase" in validate_name("Billing API")
def test_flags_alone_never_prompt():
result = runner.invoke(app, ["new", "--name", "web", "--template", "basic", "--feature", "docker"])
assert result.exit_code == 0 and "created web (basic; docker)" in result.output
def test_missing_values_without_a_terminal_fail_fast():
result = runner.invoke(app, ["new", "--name", "web"])
assert result.exit_code == 2 and "--template" in result.output
The first test pins the most important behaviour — only missing values are asked — and the last two pin the non-interactive contract that keeps automation working. For end-to-end tests of the real questionary prompts, prompt_toolkit supports piped input: create one with prompt_toolkit.input.create_pipe_input(), send keystrokes such as "j\r" (down, Enter), and pass it with output=DummyOutput() to questionary.select(..., input=..., output=...). Keep such tests few; the fake covers the logic.
Conclusion
Interactive prompts make rare, multi-decision tasks approachable, provided they sit on top of flags rather than replacing them. Ask only for what flags did not provide, choose the right question type — menus and checkboxes over free text where possible — validate as the user types, confirm a summary, and print the equivalent command at the end. Refuse to prompt without a terminal, raise on Ctrl+C instead of returning None, import the prompt library lazily, and keep the flow testable behind a small protocol.
Frequently asked questions
questionary, InquirerPy or Rich prompts?
Rich's Prompt and Confirm handle text and yes/no with nice styling but no menus. questionary and InquirerPy both provide menus, checkboxes and fuzzy selection on prompt_toolkit; questionary has a simpler API and wide use. Pick one and wrap it, as the Asker protocol does, so switching later is local.
Do these prompts work on Windows?
Yes — prompt_toolkit supports Windows consoles and Windows Terminal. Legacy consoles may render some glyphs (pointer symbols, checkbox marks) as plain characters, which remains usable.
How do I prompt for passwords?
Use questionary.password or typer.prompt(..., hide_input=True), and follow the rules in prompting for passwords securely: never echo, confirm new secrets, and always offer a non-interactive path.