Project Setup

Building a Cookiecutter Template for Typer CLIs

Create a cookiecutter template that generates a working Typer CLI: derived names, pyproject with entry points, tests, CI, and a test suite for the template itself.

Updated

Your team starts a new internal CLI every few weeks, and each one begins by copying the last one and deleting things. The copies drift: one has the current CI workflow, another an old one; one has --version wired correctly, another prints 0.1.0 forever; the new hire's project has no tests at all. A project template fixes this by encoding "how we build CLIs here" once and generating it on demand. This guide builds a cookiecutter template that produces a complete Typer CLI — src/ layout, pyproject.toml with a console script, a first command with tests, a CI workflow — with names derived from a single answer so they cannot disagree, and a test suite for the template itself so it stays working. It belongs to the CLI project scaffolding with cookiecutter topic.

Prerequisites

  • Python 3.10+ and uv; cookiecutter runs on demand with uvx cookiecutter.
  • An existing CLI you consider a good example — the template will be extracted from it.
  • If you have not chosen between cookiecutter and Copier yet, Copier vs cookiecutter for CLI templates compares them; the structure below translates to Copier with minor changes.

The shape of the template

A cookiecutter template is a repository with a cookiecutter.json describing the questions to ask, and a directory whose name is itself a template expression. Everything inside that directory is rendered through Jinja, file contents and file names alike.

Inside a CLI template repository The layout of a cookiecutter template for Typer CLIs: the variables file, hooks, and the templated project directory with its package, tests and pyproject. Inside a CLI template repository cookiecutter-typer-cli/ the template repo cookiecutter.json questions + defaults hooks/ pre/post generation {{cookiecutter.slug}}/ the generated project everything under the {{ }} directory is rendered with Jinja; a top-level tests/ checks the template Test the template itself, not just the projects it generates.
cookiecutter-typer-cli/
├── cookiecutter.json
├── hooks/
│   └── post_gen_project.py
├── tests/
│   └── test_template.py
└── {{cookiecutter.project_slug}}/
    ├── pyproject.toml
    ├── README.md
    ├── .github/workflows/ci.yml
    ├── src/{{cookiecutter.package_name}}/
    │   ├── __init__.py
    │   ├── __main__.py
    │   └── cli.py
    └── tests/
        └── test_cli.py

The recipe: questions and derived names

Ask as few questions as possible and derive everything else. A human-friendly project name produces the distribution name, the package name and the default command name through Jinja filters, so the three can never disagree:

{
  "project_name": "My Tool",
  "project_slug": "{{ cookiecutter.project_name.lower().strip().replace(' ', '-').replace('_', '-') }}",
  "package_name": "{{ cookiecutter.project_slug.replace('-', '_') }}",
  "command_name": "{{ cookiecutter.project_slug }}",
  "description": "A command-line tool.",
  "author_name": "Platform Team",
  "python_min": ["3.10", "3.11", "3.12"],
  "include_docker": ["no", "yes"],
  "__prompts__": {
    "project_name": "Human-readable project name",
    "command_name": "Command users will type",
    "python_min": "Oldest supported Python"
  }
}

Variables whose default refers to another variable are computed after the earlier answers, and users can still override them — useful when the command should be shorter than the project name. A list as the value makes a choice prompt with the first element as default. Keys starting with a double underscore, like __prompts__, customise the prompts without becoming variables.

Template variables and where they land The variables a CLI template asks for and the files each one ends up in within the generated project. Template variables and where they land Variable Example Used in project_name Deploy Helper README, help text slug (derived) deploy-helper dir name, [project].name package (derived) deploy_helper src/ package, imports command dh [project.scripts] python_min 3.10 requires-python, CI matrix Derive names from one answer with Jinja filters so they can never disagree.

The recipe: the generated files

The heart of the template is a pyproject.toml that produces a working, installable CLI:

# {{cookiecutter.project_slug}}/pyproject.toml
[project]
name = "{{ cookiecutter.project_slug }}"
version = "0.1.0"
description = "{{ cookiecutter.description }}"
readme = "README.md"
requires-python = ">={{ cookiecutter.python_min }}"
authors = [{ name = "{{ cookiecutter.author_name }}" }]
dependencies = ["typer>=0.12"]

[project.scripts]
{{ cookiecutter.command_name }} = "{{ cookiecutter.package_name }}.cli:app"

[dependency-groups]
dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.10"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.ruff]
target-version = "py{{ cookiecutter.python_min.replace('.', '') }}"

And a first command that already follows the house conventions — a version flag from package metadata, output on the right streams, a callback so subcommands can be added without restructuring:

# {{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli.py
from importlib.metadata import version
from typing import Annotated

import typer

app = typer.Typer(no_args_is_help=True, help="{{ cookiecutter.description }}")


def _version(value: bool) -> None:
    if value:
        typer.echo(f"{{ cookiecutter.command_name }} {version('{{ cookiecutter.project_slug }}')}")
        raise typer.Exit()


@app.callback()
def main(
    _: Annotated[bool, typer.Option("--version", callback=_version, is_eager=True,
                                    help="Show the version and exit.")] = False,
) -> None:
    """{{ cookiecutter.description }}"""


@app.command()
def hello(name: Annotated[str, typer.Argument(help="Who to greet.")] = "world") -> None:
    """Print a greeting (replace me with your first real command)."""
    typer.echo(f"Hello, {name}!")

The generated tests/test_cli.py tests --version and hello with CliRunner, so the new project starts with a passing suite and a pattern to copy — see testing Click commands with CliRunner. Including __main__.py (from .cli import app; app()) makes python -m package_name work too, as recommended in best practices for Python CLI entry points.

Files that contain their own {{ }}

GitHub Actions workflows use ${{ ... }} syntax, which Jinja will try to render. Wrap such files in {% raw %}...{% endraw %}, or add them to _copy_without_render in cookiecutter.json, so they are copied verbatim:

"_copy_without_render": [".github/workflows/*.yml"]

If a workflow needs a template value as well — the Python version matrix, for instance — use {% raw %} around the Actions expressions only, and leave the cookiecutter expressions outside it.

Evolving the template over time

A template is never finished. The CI workflow gets a new job, the team adopts a new lint rule, Typer's recommended style changes, the oldest supported Python moves up. Treat the template like any other internal product:

  • Version it with tags. Users can then generate from a known-good version (cookiecutter gh:acme/cookiecutter-typer-cli --checkout v3.2.0), and a broken change on main does not affect everyone at once.
  • Keep a changelog. Projects generated from older versions need to know what changed so they can adopt improvements by hand; a short entry per release — "CI now tests Python 3.14", "added doctor command" — is enough.
  • Extract from reality. When a project generated from the template develops a better pattern, move it back into the template. The template should reflect the best current practice in your team's real tools, not an idealised design nobody uses.
  • Delete as readily as you add. Every option multiplies the combinations you must test. If nobody chose include_docker = "yes" in a year, remove the option.
  • Pin the tooling the template relies on. If the post-generation hook runs uv lock, document the minimum uv version the generated project expects, and test the template against it in CI.

A template that drifts out of date is worse than none, because it teaches outdated habits with the authority of an official starting point. A few minutes of maintenance per release keeps it the fastest way to start a correct CLI.

UX considerations

The users of a template are the developers who generate projects from it:

  • Ask little, derive a lot. Every extra prompt is friction and a chance for inconsistency. Three or four questions is plenty.
  • Make the first run succeed. The generated project should install, pass its tests and print --help immediately. A template whose output needs fixing teaches people to distrust it.
  • Print the next steps. A post-generation hook that prints cd my-tool && uv sync && uv run my-tool --help gets people moving; see post-generation hooks in CLI templates.
  • Keep opinions documented. The generated README should say why the layout is what it is and link to the team's conventions, so the template teaches rather than just copies.
Generating a new CLI in seconds Terminal output of running cookiecutter against a template, answering prompts, and running the generated project tests. Generating a new CLI in seconds bash $ uvx cookiecutter gh:acme/cookiecutter-typer-cli [1/4] project_name (My Tool): Deploy Helper [2/4] command (deploy-helper): dh $ cd deploy-helper && uv run pytest -q && uv run dh --help 3 passed in 0.21s A generated project that passes its own tests on first run is the template's real acceptance test.

Testing the behaviour

A template is software, and it breaks like software: a renamed variable, a Jinja typo, a workflow that no longer parses. Test it by generating projects and running them. cookiecutter has a Python API that makes this straightforward in pytest:

# tests/test_template.py
import subprocess
import sys
from pathlib import Path

import pytest
from cookiecutter.main import cookiecutter

TEMPLATE = Path(__file__).parent.parent


@pytest.mark.parametrize("docker", ["no", "yes"])
def test_generated_project_works(tmp_path, docker):
    out = cookiecutter(
        str(TEMPLATE), no_input=True, output_dir=str(tmp_path),
        extra_context={"project_name": "Deploy Helper", "command_name": "dh",
                       "include_docker": docker},
    )
    project = Path(out)
    assert project.name == "deploy-helper"
    assert (project / "src" / "deploy_helper" / "cli.py").exists()
    assert (project / "Dockerfile").exists() == (docker == "yes")

    def run(*cmd: str) -> subprocess.CompletedProcess[str]:
        return subprocess.run(cmd, cwd=project, capture_output=True, text=True, check=True)

    run("uv", "sync", "--group", "dev")
    run("uv", "run", "pytest", "-q")
    assert run("uv", "run", "dh", "--version").stdout.strip() == "dh 0.1.0"
    run("uv", "run", "ruff", "check", ".")

This single test catches almost every class of template bug: rendering errors, broken derived names, a pyproject.toml that does not build, a first command that fails its own tests, and lint violations in the generated code. Run it in the template repository's CI across the same Python versions the template offers. The Docker parameter shows the pattern for optional features: every combination the template supports should be generated at least once.

Conclusion

A good CLI template is a small, opinionated, tested program that generates other programs. Ask for a project name and derive the rest, generate a pyproject.toml with a console script, a first command that already follows your conventions, tests and CI, protect workflow files from Jinja, and test the template by generating projects and running their tests. New CLIs then start from the team's best current practice instead of from whichever old project was copied last.

Frequently asked questions

How do existing projects get template updates?

Cookiecutter generates once and forgets. Tools like cruft record the template version and can apply later template changes as a diff. If keeping many projects in sync matters, Copier's built-in update support is the stronger reason to choose it.

Should the template pin dependency versions?

Use lower bounds in pyproject.toml (typer>=0.12) and let the post-generation hook run uv lock so each new project starts with current, locked versions. Pinning exact versions in the template makes every new project start out of date.

Can I host the template privately?

Yes. cookiecutter gh:org/template works with private GitHub repositories if your git credentials allow access, and any git URL or local path works too. A zip file served internally is another option for restricted networks.

How do I include a license choice?

Add a choice variable ("license": ["MIT", "Apache-2.0", "Proprietary"]), keep one license text per option in the template, and have the post-generation hook delete the files for the options not chosen.