Project Setup

Poetry Entry Points and Scripts for CLIs

Define console entry points with the Poetry scripts table, expose your CLI command on install, and test it with poetry run and editable installs before shipping.

Updated

An entry point is the line of metadata that turns your Python package into a command someone can type. Without it, users have to run python -m yourpackage; with it, they type yourcli and it just works. Poetry declares these console scripts in a small table in pyproject.toml, and poetry install generates the wrapper executable that puts your command on PATH. This guide shows the exact syntax, both the Poetry-specific and the standards-based table, and how to verify the command actually resolves before you publish.

TL;DR

  • Map a command to a callable in [tool.poetry.scripts]: mycli = "mypackage.cli:main".
  • On poetry-core >= 2.0 you can (and should) use the standard PEP 621 [project.scripts] table instead — same syntax, tool-agnostic.
  • poetry install creates the console-script wrapper; the command is then on PATH inside the project environment.
  • Test it without publishing: poetry run mycli, or activate the environment and call mycli directly.
  • The target callable takes no arguments — Click and Typer read sys.argv themselves. Don't write main(sys.argv).

The scripts table: mapping a command to a callable

The console-script declaration has one job: bind a command name to an importable callable. Poetry's historical table is [tool.poetry.scripts]:

From the scripts table to a command The scripts table maps a command name to a module and callable, which the installer turns into an executable shim on the PATH. From the scripts table to a command [project.scripts] mytool = "myapp.cli:app" Installer poetry install / pip Shim on PATH a tiny generated script myapp.cli:app called with no arguments read at install generates imports & calls The shim is generated at install time, which is why editing the table needs a re-install to take effect.
[tool.poetry]
name = "mycli"
version = "0.1.0"
description = "A friendly command-line tool."
packages = [{ include = "mycli", from = "src" }]

[tool.poetry.scripts]
mycli = "mycli.cli:app"

[build-system]
requires = ["poetry-core>=1.8"]
build-backend = "poetry.core.masonry.api"

The value "mycli.cli:app" is an object reference: the part before the colon is the module to import (mycli.cli), and the part after is the attribute to call (app). When someone runs mycli, the generated wrapper imports that module and calls that object with no arguments.

Here is the matching src/mycli/cli.py using Typer, whose app object is directly callable:

import typer

app = typer.Typer(help="A friendly command-line tool.")


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


if __name__ == "__main__":
    app()

With Click the target is usually the @click.group() or @click.command()-decorated function, which is also callable with no arguments:

import click


@click.group()
def app() -> None:
    """A friendly command-line tool."""


@app.command()
@click.option("--name", default="world")
def hello(name: str) -> None:
    click.echo(f"Hello, {name}!")

Either way the scripts table points at app, and Poetry does the rest. The deeper rules for choosing a good target — why a thin main() wrapper beats calling framework internals — are in best practices for Python CLI entry points.

The PEP 621 equivalent: [project.scripts]

Since poetry-core 2.0, Poetry fully supports the standardized [project] metadata table, and console scripts move to [project.scripts]. This is the form to prefer in new projects because any PEP 517 build backend understands it — your entry points survive a future switch away from Poetry.

[project]
name = "mycli"
version = "0.1.0"
description = "A friendly command-line tool."
requires-python = ">=3.11"
dependencies = ["typer>=0.12"]

[project.scripts]
mycli = "mycli.cli:app"

[tool.poetry]
packages = [{ include = "mycli", from = "src" }]

[build-system]
requires = ["poetry-core>=2.0"]
build-backend = "poetry.core.masonry.api"

The syntax inside the table is identical — command = "module:callable". What changes is the table name and portability: [tool.poetry.scripts] is only read by poetry-core, while [project.scripts] is the interoperable standard that pip, uv, hatchling, and every other modern tool reads. Note you cannot split metadata arbitrarily: if you declare [project], dependencies and scripts belong under [project], and [tool.poetry] shrinks to Poetry-specific settings like packages. This is the same convergence discussed in uv init vs poetry init for CLI tools.

poetry install makes the command available

Declaring the script is not enough — a build step has to generate the wrapper executable. poetry install does that as part of installing your own project into its environment:

When the command becomes available The sequence from poetry install to a working command: the project is installed in editable mode, the shim is written into the environment, and the shell finds it on PATH. When the command becomes available You Poetry venv/bin Shell poetry install write the mytool shim mytool --version found on PATH inside the venv Outside the environment the shim is not on PATH — that is what poetry run exists to bridge.
$ poetry install
Installing dependencies from lock file
Installing the current project: mycli (0.1.0)

$ poetry run mycli hello --name Ada
Hello, Ada!

poetry install performs an editable install of your project by default (equivalent to pip install -e .): it links your source directory into the environment rather than copying it, and it writes the console-script wrapper into the environment's bin/ (or Scripts\ on Windows). Because the install is editable, edits to cli.py take effect immediately — no reinstall needed. What does require a re-run of poetry install is changing the scripts table itself, since the wrapper is generated from that metadata.

Testing the entry point with poetry run and an activated shell

You have two ways to invoke the command inside the managed environment without publishing anything.

The quickest is poetry run, which runs a single command in the environment:

$ poetry run mycli --help
$ poetry run mycli hello --name world

If you are iterating and want the command available across many invocations, activate the environment instead. On Poetry 2.x this is the env activate command (older versions used poetry shell):

$ eval $(poetry env activate)
(mycli-py3.11) $ mycli hello
Hello, world!
(mycli-py3.11) $ which mycli
/home/you/.cache/pypoetry/virtualenvs/mycli-py3.11/bin/mycli

which mycli pointing inside the Poetry virtualenv is the proof that the wrapper was generated and put on PATH. If the command is not found after poetry install, the usual causes are a typo in the object reference, a package that was not actually included (check the packages setting), or forgetting that the current project must be installed — poetry install --no-root deliberately skips your package and therefore its scripts.

Verifying the object reference resolves

A subtle failure mode is a scripts table that builds fine but whose target cannot be imported at runtime — a renamed module, a callable that moved, or a typo. Catch it early by importing the reference yourself:

Proving the target actually resolves A terminal session importing the entry point target directly to confirm the module and callable named in the scripts table exist. Proving the target actually resolves bash $ poetry run python -c "from myapp.cli import app; print(app)" <typer.main.Typer object at 0x7f2c1d0b8e50> # and the generated console script itself $ poetry run which mytool /home/you/.cache/pypoetry/virtualenvs/mytool-py3.12/bin/mytool A typo in the scripts table fails here in one second, instead of failing for a user after publishing.
# Does the module import and does the callable exist?
$ poetry run python -c "from mycli.cli import app; print(app)"
<typer.main.Typer object at 0x...>

If that line raises ModuleNotFoundError or ImportError, your mycli command would fail with the same error, just wrapped in console-script boilerplate. This one-liner belongs in a smoke test in CI so a broken entry point never reaches users. It pairs well with the checks in setting up pre-commit for Python CLI repos.

For an even stricter check, build the wheel and inspect the metadata that will actually ship. The entry point is written into the distribution's entry_points.txt, and reading it back confirms the exact command-to-callable mapping a user's pip install will register:

$ poetry build
$ python -c "
from importlib.metadata import distribution
d = distribution('mycli')
for ep in d.entry_points.select(group='console_scripts'):
    print(ep.name, '->', ep.value)
"
mycli -> mycli.cli:app

Seeing mycli -> mycli.cli:app printed from the installed metadata — not just the source pyproject.toml — is the strongest confirmation you can get short of publishing. It is also how tools like pipx and uv discover which commands to expose when someone installs your CLI globally.

Scripts vs plugins: two different mechanisms

[tool.poetry.scripts] / [project.scripts] register console scripts — commands your user runs. They are unrelated to Poetry's own plugins, which extend the poetry tool itself and are declared under [tool.poetry.plugins] against a named entry-point group. If you are building an extensible CLI where third parties register subcommands into your tool, that is the same underlying entry-point-group mechanism, covered in plugin architectures for extensible CLIs. Keep the two mental models separate: the scripts table ships your command; entry-point groups let other packages plug into a host application.

Production notes

  • The target must accept zero arguments. mycli = "mycli.cli:main" calls main() with no args. Frameworks parse sys.argv internally, so a main(argv) signature will crash the wrapper.
  • Prefer [project.scripts] for new projects. It is portable across build backends; reserve [tool.poetry.scripts] for repos still on poetry-core < 2.0.
  • Re-run poetry install after editing the scripts table. Source edits are picked up automatically (editable install), but wrapper regeneration is not.
  • Multiple commands are fine. Add more lines to the table (mycli = ..., mycli-admin = ...) to ship several executables from one package.
  • Cross-platform wrappers differ. Poetry writes a bin/ script on Unix and a .exe shim in Scripts\ on Windows; both resolve the same object reference, so no per-OS code is needed.
  • Don't hardcode the entry point in tests. Test the callable directly (from mycli.cli import app) using Click's CliRunner or Typer's test runner rather than shelling out to the installed command.

Frequently asked questions

What is the difference between tool.poetry.scripts and project.scripts?

They express the same thing in two dialects. project.scripts is the standardised PEP 621 table that every backend understands; tool.poetry.scripts is Poetry's older equivalent. Recent Poetry supports the standard table, and using it is the better default because the file then means the same thing to pip, uv, build and anything else.

Why is my command not found after poetry install?

Because the shim lives inside the project's virtual environment, not on your global PATH. Either prefix the command with poetry run, or activate the environment first. If it is missing even there, the target named in the scripts table does not resolve — check it by importing it directly with poetry run python -c.

Do I need to re-install after changing the scripts table?

Yes. The launcher is generated during installation, so a renamed command or a changed target only takes effect on the next poetry install. Editing the table and wondering why nothing changed is the single most common confusion here.

Can one project ship several commands?

Yes — add one line per command to the table, each pointing at its own callable. That is how tools ship a main command plus a small companion like a doctor or a migration helper. Keep the number small: each name is a permanent claim on the user's PATH.

Are scripts and plugin entry points the same mechanism?

They use the same underlying entry-point metadata but different groups. console_scripts creates executables on PATH; a custom group such as mytool.plugins is read at runtime by your own code to discover extensions. Poetry writes both from pyproject.toml, and confusing the two is why a plugin sometimes shows up as a stray command.