Helen: one command, one line on stderr | Chirag Patil

Reading time4 min

Helen: one command, one line on stderr

Line illustration on deep indigo: a small smiling robot on a stool types at a chunky old terminal, and a single ribbon of light floats out of the screen into a sky of stars, beside a sleeping cat and potted plants

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:

main.py
import typer
from 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

Three real runs. A plain run writes one line to stderr and nothing to stdout; --help goes to stdout; a stray argument is an error on stderr with exit code 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.

With a single command, Typer runs it as the root. Add a second command and the interface changes shape.

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 CliRunner that pins the exit code and checks the message lands on stderr, not stdout.
  • Declare rich as 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.