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.
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.
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 onmaindoes 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
doctorcommand" — 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
--helpimmediately. 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 --helpgets 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.
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.