Helen: one command, one line on stderr

How the first Helen slice is wired: uv, Typer, and a Rich console bound to standard error, and what running it taught me.
Helen is a command-line tool that does almost nothing yet, and I wanted to understand exactly what that nothing is before adding to it. The repository is one Python file, a pyproject.toml, and a uv.lock. There are no commits. The program prints one sentence to standard error and exits. That is small enough to read completely, run, and describe without guessing, so this post does that.
The problem
Every CLI I start eventually needs the same things: argument parsing, help text, exit codes, and a clear split between output a script can consume and messages meant for a person. Helen’s first slice settles the tooling for those before there is any feature to build. The project’s job is not decided. The code doesn’t say, so I won’t either.
Architecture
The whole program is main.py:
import typerfrom rich.console import Console
err_console = Console(stderr=True)
app = typer.Typer()
@app.command()def main(): err_console.print("Here is something written to standard error")
if __name__ == "__main__": app()The call path has four stops. The __main__ guard hands control to the Typer app. Typer parses argv, finds the one registered command, and calls main(). main() writes through a Rich Console that was created with stderr=True.
uv run python main.py
stdout
(empty)
stderr
Here is something written to standard error
exit 0
uv run python main.py --help
stdout
Usage: main.py [OPTIONS] ╭─ Options ──────────────────────────────────────────────╮ │ --install-completion Install completion for │ │ the current shell. │ │ --show-completion Show completion for the │ │ current shell, to copy │ │ it or customize the │ │ installation. │ │ --help Show this message and │ │ exit. │ ╰────────────────────────────────────────────────────────╯
stderr
(empty)
exit 0
uv run python main.py extra
stdout
(empty)
stderr
Usage: main.py [OPTIONS] Try 'main.py --help' for help. ╭─ Error ────────────────────────────────────────────────╮ │ Got unexpected extra argument(s) (extra) │ ╰────────────────────────────────────────────────────────╯
exit 2
uv handles packaging. pyproject.toml asks for Python 3.13 or newer and one direct dependency, typer>=0.26.7. .python-version pins 3.13. The lockfile resolves Typer 0.26.7 and Rich 15.0.0.
Design decisions and tradeoffs
Typer over argparse. Typer builds the parser from function signatures and type hints, so adding an option later means adding a parameter. The cost is a dependency tree instead of the standard library. I expect Helen to grow commands, so I will take that trade.
Rich comes in through Typer. main.py imports rich directly, but pyproject.toml only lists Typer. It works because Typer depends on Rich, and the lock pins it. That’s an implicit contract. If Typer ever drops Rich, the import breaks. Declaring rich directly is a one-line fix I haven’t made yet.
Messages go to stderr from the start. The only output is on the error stream. Keeping stdout empty means anything Helen eventually prints there can be treated as data and piped, while status and diagnostics stay on stderr. Binding the console once at module level, Console(stderr=True), makes that choice in one place instead of at every print call.
A script, not an installed command. There is no [project.scripts] entry, so there is no helen executable. You run python main.py. That keeps the first slice honest: nothing claims to be installable until it is.
A detail worth knowing: one command becomes the root
When a Typer app has exactly one command, Typer doesn’t make you name it. python main.py runs main() directly. The help output shows no subcommand list:
$ python main.py --help Usage: main.py [OPTIONS] ...│ --install-completion Install completion for the current shell.│ --show-completion Show completion for the current shell, ...│ --help Show this message and exit.Because main() takes no parameters, any extra argument is rejected with exit code 2:
$ python main.py extra Got unexpected extra argument(s) (extra)One command.main sits right on the app, so python main.py runs it.
Two commands. A group slides in between, and you have to name main to reach it.
This matters for what comes next. As soon as a second @app.command() is added, Typer switches to subcommand mode, and python main.py alone would no longer run main(). Anyone scripting against today’s interface would break. If Helen is going to have several commands, I’d rather make that switch while nobody depends on it.
What broke, and what I learned
My first attempt to run it was python main.py with the system interpreter. It failed on import typer, because Typer wasn’t installed there. Running through uv worked: uv run python main.py created a virtual environment on Python 3.13, installed the locked packages, and printed the sentence on stderr with nothing on stdout and exit code 0.
The lockfile is the environment, not a suggestion. Reading the source told me what Helen should do. Only running it in the locked environment confirmed it.
What’s next
- Add a test with Typer’s
CliRunnerthat pins the exit code and checks the message lands on stderr, not stdout. - Declare
richas a direct dependency, since the code imports it. - Decide what Helen is for, then decide between one root command and subcommands before anyone relies on either.
- Add a
[project.scripts]entry once there is a command worth installing.