Project Setup

CLI Project Scaffolding with Cookiecutter

Generate production-ready Python CLI project scaffolds with Cookiecutter templates — enforce pyproject.toml structure, src layout, and team consistency.

Updated

Every new Python CLI starts with the same forgettable chores: a pyproject.toml, a src/ directory, an entry point wired into [project.scripts], a test folder, a license, and a CI file. Do this by hand once and you have a project; do it ten times across a team and you have ten subtly different projects. Cookiecutter turns that boilerplate into a parameterized template you generate in one command, so every CLI your team ships starts from the same correct skeleton.

TL;DR

  • Cookiecutter renders a directory tree from Jinja2 templates. Variables come from a cookiecutter.json at the template root; the rendered project lives under a {{cookiecutter.project_slug}}/ directory.
  • Generate a project with cookiecutter gh:your-org/cli-template (or a local path). Cookiecutter prompts for each variable, then renders the tree.
  • Put the real structure in the template: src/ layout, a pyproject.toml with [project.scripts], tests, and CI.
  • Use hooks/pre_gen_project.py to validate answers and hooks/post_gen_project.py to delete unused files, init git, or fail fast on bad input.
  • The win is team consistency — one reviewed template means every CLI has the same layout, the same entry-point convention, and the same lint config.

Cookiecutter renders a template directory with {{cookiecutter.project_slug}}/ and templated file names into a fully generated project tree.

What Cookiecutter is, and when to reach for it

Cookiecutter (pip install cookiecutter, or run it once with uvx cookiecutter) is a project generator. You point it at a template — a local directory, a Git URL, or a zip — and it asks you the questions defined in that template's cookiecutter.json, then writes out a fully rendered project with your answers substituted in.

Use it when you create new CLIs often enough that the setup tax is real, or when more than one person needs to produce projects that look the same. A single developer scaffolding one tool a year does not need it. A platform team that owns a dozen internal CLIs — each needing the same logging setup, the same --version flag, the same release workflow — gets compounding value: fix the template once and every future project inherits the fix.

The alternative most people reach for first is "copy the last project and delete the bits I don't need." That works until the copied project drifts, carries stale dependencies, or leaks a hardcoded package name into three files you forgot to rename. A template makes the rename a variable.

Template directory layout

A Cookiecutter template is itself a directory. The one rule that matters: the project being generated lives inside a directory whose name is a Jinja2 expression, conventionally {{cookiecutter.project_slug}}. Everything outside that directory (the cookiecutter.json, the hooks/) is template machinery and is not copied into the output.

cli-template/
├── cookiecutter.json
├── hooks/
│   ├── pre_gen_project.py
│   └── post_gen_project.py
└── {{cookiecutter.project_slug}}/
    ├── pyproject.toml
    ├── README.md
    ├── .pre-commit-config.yaml
    ├── src/
    │   └── {{cookiecutter.package_name}}/
    │       ├── __init__.py
    │       ├── __main__.py
    │       └── cli.py
    └── tests/
        └── test_cli.py

Notice the nested {{cookiecutter.package_name}}/ directory under src/. Cookiecutter templates both file contents and file/directory names, so the import package gets named correctly without any post-processing. This is what makes the src/ layout work cleanly: the rendered tree is src/your_package/ with a name derived from the answers, not a fixed string you have to find-and-replace.

A realistic cookiecutter.json

The cookiecutter.json defines variables and their defaults. Order matters — earlier answers can feed later defaults through Jinja2 expressions. List values become a "choose one" menu. The leading-underscore keys are reserved settings, not prompts.

{
  "project_name": "My CLI Tool",
  "project_slug": "{{ cookiecutter.project_name.lower().replace(' ', '-') }}",
  "package_name": "{{ cookiecutter.project_slug.replace('-', '_') }}",
  "command_name": "{{ cookiecutter.project_slug }}",
  "author_name": "Your Name",
  "author_email": "you@example.com",
  "python_version": "3.12",
  "cli_framework": ["typer", "click", "argparse"],
  "license": ["MIT", "Apache-2.0", "Proprietary"],
  "use_precommit": ["yes", "no"],
  "_copy_without_render": [".github/workflows/*.yml"]
}

Two derivations carry the load. project_slug turns "My CLI Tool" into my-cli-tool for the directory and the distribution name, and package_name turns that into my_cli_tool — a valid Python identifier for the import package. Getting these right in the template means the rendered project's distribution name and import name follow the standard convention (dashes in the dist name, underscores in the module) without the author thinking about it.

_copy_without_render is a safety hatch: GitHub Actions files use ${{ ... }} syntax that collides with Jinja2's {{ ... }}, so you tell Cookiecutter to copy those paths verbatim instead of trying to render them.

The generated pyproject.toml

This is the heart of the template — the file that makes the output a real, installable CLI. The Jinja2 expressions are resolved at generation time against the answers above.

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

[project]
name = "{{ cookiecutter.project_slug }}"
version = "0.1.0"
description = "{{ cookiecutter.project_name }} — a command-line tool."
authors = [{ name = "{{ cookiecutter.author_name }}", email = "{{ cookiecutter.author_email }}" }]
requires-python = ">={{ cookiecutter.python_version }}"
readme = "README.md"
license = { text = "{{ cookiecutter.license }}" }
dependencies = [
{%- if cookiecutter.cli_framework == "typer" %}
  "typer>=0.12",
{%- elif cookiecutter.cli_framework == "click" %}
  "click>=8.1",
{%- endif %}
]

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

[project.optional-dependencies]
dev = ["pytest>=8.0", "ruff>=0.5"]

[tool.hatch.build.targets.wheel]
packages = ["src/{{ cookiecutter.package_name }}"]

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

The [project.scripts] line is what installs the mytool command onto the user's PATH. It points at package.cli:app — the framework's callable in the rendered module. The {%- if %} block adds the framework dependency that matches the chosen cli_framework, and only that one. The [tool.hatch.build.targets.wheel] section tells the build backend that the package lives under src/, which is required for the src/ layout to package correctly. For the deeper rationale on choosing app versus a function and registering it as a console script, see best practices for Python CLI entry points.

The rendered src/{{cookiecutter.package_name}}/cli.py is the matching minimal entry point:

import typer

app = typer.Typer(help="{{ cookiecutter.project_name }}")


@app.command()
def hello(name: str = "world") -> None:
    """Print a greeting."""
    typer.echo(f"Hello, {name}!")


if __name__ == "__main__":
    app()

Pre- and post-generation hooks

Hooks are ordinary Python scripts in hooks/. The pre_gen_project.py runs before rendering (in a temp context) and is the place to reject invalid answers; post_gen_project.py runs after rendering, with the current working directory set to the root of the freshly generated project. The post hook is where you delete files the chosen options don't need, initialize git, or print next-steps.

A pre_gen_project.py validation guard — exit non-zero and Cookiecutter aborts the whole generation, leaving nothing behind:

import re
import sys

PACKAGE_NAME = "{{ cookiecutter.package_name }}"

if not re.match(r"^[a-z][a-z0-9_]+$", PACKAGE_NAME):
    print(f"ERROR: '{PACKAGE_NAME}' is not a valid Python package name.")
    sys.exit(1)

The post hook does the cleanup and git init. This is real, runnable Python — Cookiecutter substitutes the {{ ... }} literal before executing it, so the conditional reads a plain string at runtime:

"""Post-generation hook: runs from the root of the freshly rendered project."""

from __future__ import annotations

import shutil
import subprocess
import sys
from pathlib import Path

PROJECT_ROOT = Path.cwd()

# Cookiecutter renders this literal into the conditional below at generation time.
USE_PRECOMMIT = "{{ cookiecutter.use_precommit }}" == "yes"


def remove_unused_paths() -> None:
    """Delete optional files the chosen options don't need."""
    if not USE_PRECOMMIT:
        config = PROJECT_ROOT / ".pre-commit-config.yaml"
        config.unlink(missing_ok=True)


def init_git_repo() -> None:
    """Initialize a git repo if git is available; never fail the generation."""
    if shutil.which("git") is None:
        print("git not found on PATH — skipping repo init", file=sys.stderr)
        return
    try:
        subprocess.run(["git", "init", "--quiet"], cwd=PROJECT_ROOT, check=True)
    except subprocess.CalledProcessError as exc:
        print(f"git init failed ({exc.returncode}) — continuing", file=sys.stderr)


def main() -> int:
    remove_unused_paths()
    init_git_repo()
    print(f"Scaffolded project in {PROJECT_ROOT}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

The pattern worth copying: cleanup is driven by pathlib with missing_ok=True so a second run never crashes, and anything that touches the outside world (git) degrades gracefully instead of aborting a generation that already succeeded. A post hook that raises on a missing optional file is the most common way to leave a half-generated project on disk.

Generating a project

With the template published — or sitting in a local directory — generation is one command:

# From a Git host (GitHub shortcut):
cookiecutter gh:your-org/cli-template

# From a local checkout:
cookiecutter ./cli-template

# Run without installing it globally:
uvx cookiecutter gh:your-org/cli-template

# Skip the prompts in CI, overriding only what you need:
cookiecutter gh:your-org/cli-template --no-input \
  project_name="Deploy Bot" cli_framework=click

After generation, the rendered project is ready to install in editable mode and run:

cd deploy-bot
uv venv && uv pip install -e ".[dev]"
deploy-bot hello --name team

Why this works (and the trade-offs)

The value of Cookiecutter is centralization. The structural decisions — src/ layout, where the entry point lives, which build backend, how lint is configured — get made once, in a template that a senior engineer reviews, instead of re-litigated in every new repo. When you bump the minimum Python version or switch build backends, you change the template and every project generated afterward is correct.

The trade-off is that Cookiecutter is a one-shot generator: it scaffolds a project at time zero and then walks away. Once a developer runs it, their project is theirs — there is no link back to the template, so improvements you make later do not flow into already-generated projects. Tools like Cruft and Copier exist specifically to add that update path. If "keep 40 services in sync with the template" is your actual problem, evaluate Copier instead; if "stop people hand-rolling pyproject.toml" is the problem, Cookiecutter is the right size.

The second trade-off is templating noise. A pyproject.toml full of {%- if %} blocks is harder to read and easy to break — a misplaced whitespace-control marker ({%- vs {%) silently mangles your output. Keep conditionals shallow; if a template grows three levels of nested logic, that is a sign you want two templates, not one heroic one.

Production notes

  • Test the template, not just the output. Cookiecutter has a pytest plugin, pytest-cookies, that bakes the template into a temp directory inside a test. Add a CI job that bakes the project, then runs uv pip install -e . and the generated test suite inside it. A template that generates a project that does not install is worse than no template.
  • _copy_without_render for collision-prone files. Any file that legitimately contains {{ or {% — GitHub Actions workflows, some YAML config, Jinja-templated app files — must be listed there, or Cookiecutter will try to render it and fail or corrupt it.
  • Pin the framework versions in the template. The generated pyproject.toml above uses floors like typer>=0.12. That gives new projects a known-good baseline; let each project's lockfile pin the exact versions.
  • Hooks run with the interpreter that runs Cookiecutter. They are not isolated in the project's venv. Keep them to the standard library (pathlib, subprocess, re, shutil) so they work regardless of what the generated project depends on.
  • Idempotent cleanup. Use unlink(missing_ok=True) and shutil.rmtree(..., ignore_errors=True) in post hooks so re-runs and partial states do not crash.