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 installcreates the console-script wrapper; the command is then onPATHinside the project environment.- Test it without publishing:
poetry run mycli, or activate the environment and callmyclidirectly. - The target callable takes no arguments — Click and Typer read
sys.argvthemselves. Don't writemain(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]:
[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:
$ 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:
# 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"callsmain()with no args. Frameworks parsesys.argvinternally, so amain(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 installafter 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.exeshim inScripts\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'sCliRunneror Typer's test runner rather than shelling out to the installed command.
Related
- Poetry workflows for CLI development — the parent guide covering the full Poetry lifecycle for CLI projects.
- Best practices for Python CLI entry points — how to design the callable your scripts table points at.
- uv init vs poetry init for CLI tools — the same scripts table seen from uv's side.
- Plugin architectures for extensible CLIs — entry-point groups for letting third parties extend your CLI.