Skip to main content

Command Palette

Search for a command to run...

Setting up a CLI with Typer

Updated
3 min readView as Markdown
B
I build CLI tools and developer productivity scripts in Python and Rust.

I'd used argparse before. It's clunky - lots of boilerplate for even simple commands. Typer works differently. You write a normal function with type annotated parameters. Typer builds the CLI from the signature.

A basic command

Here's the init command as it shipped with v0.1.0:

from pathlib import Path
from typer import Typer
from typer import Argument as typer_argument
from typer import Option as typer_option

app = Typer(help="Scaffold a new project locally")

@app.command("init")
def init(
    project_name: str = typer_argument(
        ..., help="Name of the new project"
    ),
    path: Path = typer_option(
        Path("."), help="Where to create the project"
    ),
) -> None:
    """Scaffold a new project locally."""
    ...

No parser setup. No add_argument calls. The function signature is the whole CLI definition.

Arguments vs options

Typer splits inputs into two kinds: arguments and options.

project_name uses typer_argument. Arguments are positional and required by default. Run scaffoldr init myproject, and myproject fills that slot. The ... as the first value tells Typer that this argument has no default - it must be provided.

path uses typer_option. Options use --flag value syntax, like scaffoldr init myproject --path ~/projects. Options always have a default, set by the first value passed to typer_option. Skip the flag, and it falls back to Path(".").

Type hints drive validation

Look at path: Path. Typer reads that type hint. It converts whatever the user types into a pathlib.Path object. No conversion needed. It arrives as the right type inside the function.

Booleans work the same way. For example:

protect: bool = typer_option(
    True, help="Enable branch protection on main."
)

Typer creates --protect and --no-protect flags automatically. No extra code for that either.

Docstrings become help text

The docstring under the function - """Scaffold a new project locally.""" - isn't just for developers reading the code. Typer shows it when someone runs scaffoldr init --help. The help= argument on typer_argument and typer_option works the same way. It shows per-parameter help text.

The function signature and docstring are the only source of truth for the CLI's documentation.

Registering the entry point

Once the function is decorated with @app.command("init"), it needs to be wired into the installable CLI. In pyproject.toml:

[project.scripts]
scaffoldr = "scaffoldr.main:app"

This tells Python's packaging tools to create a scaffoldr executable. It runs the app object from scaffoldr/main.py. app is the Typer instance every command gets registered to.

What's next

With the CLI shell in place, the next problem was defining what gets created when someone runs scaffoldr init - the folder structure and files that make up a scaffolded project. In v0.1.0, this was hardcoded. That's covered in the next post.

Building scaffoldr v0.1.0

Part 2 of 2

A behind-the-scenes look at building scaffoldr - a CLI tool that scaffolds new Python projects with GitHub integration. This series walks through the local scaffolding engine, GitHub API integration, automated issue creation, branch protection, and CI generation - the core features that shipped with v0.1.0.

Start from the beginning

I kept forgetting things at project setup - so I built scaffoldr

Everytime I created a new project, I'd go through the same ritual. Create the repo, clone it, set up the directory structure, write the initial pyproject.toml, configure CI, add branch protection, cre