Architecture

Building a Python CLI with Cleo Command Classes

Build a namespaced CLI with Cleo, the framework behind Poetry: command classes, options, styled output, verbosity, questions and CommandTester tests.

Updated

Most Python CLI frameworks describe commands as decorated functions. Cleo describes them as classes: each command declares its name, arguments and options as class attributes and does its work in a handle() method, with a rich I/O object for output, questions, tables and progress bars. If you have used Poetry, you have used Cleo — every poetry subcommand is a Cleo command. The class-based model suits applications with many commands that share services and conventions, and namespaced names such as db migrate and cache clear come built in. This guide builds a small application with Cleo 2, covers its console I/O and verbosity levels, notes the behaviours that differ from Click, and tests it with Cleo's testers. It belongs to the alternative Python CLI frameworks topic.

Prerequisites

  • Python 3.10+ and cleo (uv add cleo); examples were checked against Cleo 2.1.
  • Comfort with classes and inheritance.
  • Optional: the structural ideas in how to structure a large Python CLI project, which map well onto command classes.

The shape of a Cleo application

An Application holds commands. Each Command subclass declares name, description, arguments and options; Cleo builds the parser from those declarations and calls handle(), whose integer return value is the exit code. A space in a command name creates a namespace, and the built-in list command groups commands by namespace.

A Cleo application A Cleo application holding command classes, with a space in a command name creating a namespace, plus built-in help and list commands. A Cleo application Application("greeter") global -q, -v, -n, --ansi greet GreetCommand db migrate namespace "db" help built in list built in each command declares arguments and options as class attributes The integer returned by handle() becomes the exit code.
# src/greeter/commands.py
from __future__ import annotations

from cleo.commands.command import Command
from cleo.helpers import argument, option


class GreetCommand(Command):
    name = "greet"
    description = "Greet someone."
    arguments = [argument("name", description="Who to greet.", optional=True)]
    options = [
        option("yell", "y", description="Uppercase the greeting.", flag=True),
        option("times", "t", description="How many times.", flag=False, default="1"),
    ]

    def handle(self) -> int:
        name = self.argument("name") or "world"
        try:
            times = int(self.option("times"))
        except ValueError:
            self.line_error("<error>--times must be a whole number</error>")
            return 2
        text = f"Hello {name}"
        if self.option("yell"):
            text = text.upper()
        for _ in range(times):
            self.line(f"<info>{text}</info>")
        if self.io.is_verbose():
            self.line_error(f"<comment>greeted {name} {times}x</comment>")
        return 0


class DbMigrateCommand(Command):
    name = "db migrate"
    description = "Apply pending database migrations."
    options = [option("dry-run", None, "Only print the plan.", flag=True)]

    PENDING = ["0004_add_index", "0005_backfill", "0006_drop_legacy"]

    def handle(self) -> int:
        self.table(["Migration", "Status"], [[m, "pending"] for m in self.PENDING]).render()
        if self.option("dry-run"):
            return 0
        if not self.confirm(f"Apply {len(self.PENDING)} migrations?", False):
            self.line_error("<comment>aborted</comment>")
            return 1
        self.line(f"applied {len(self.PENDING)} migrations")
        return 0
# src/greeter/app.py
from cleo.application import Application

from greeter.commands import DbMigrateCommand, GreetCommand


def create_app() -> Application:
    app = Application("greeter", "0.3.0")
    app.add(GreetCommand())
    app.add(DbMigrateCommand())
    return app


def main() -> int:
    return create_app().run()


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

A create_app() factory keeps construction separate from running, which tests use below. The main() function is the console-script entry point.

Console I/O, styles and verbosity

Cleo's output layer is its biggest difference from function-based frameworks. Inside handle(), self.line() writes to stdout and self.line_error() to stderr; both understand inline style tags — <info>, <comment>, <question>, <error> — that render as colours on a terminal and disappear when output is redirected or --no-ansi is passed. self.table(), self.progress_bar() and self.spin() cover tabular and long-running output, and self.confirm(), self.ask(), self.choice() and self.secret() handle questions.

Cleo console helpers The console I/O helpers available inside a Cleo command and what each is used for. Cleo console helpers Helper Use it for Stream self.line() data and results stdout self.line_error() status, warnings, errors stderr self.table() tabular output stdout self.confirm() / ask() questions; defaults under -n prompt self.io.is_verbose() extra detail at -v — Style tags such as <info> and <error> render as colour on a terminal and vanish when redirected.

Every Cleo application also gets global options for free: -q/--quiet, -v/-vv/-vvv verbosity levels, --ansi/--no-ansi and -n/--no-interaction. Commands check verbosity with self.io.is_verbose(), is_very_verbose() and is_debug(), which maps neatly onto the logging advice in adding verbose and quiet logging flags. With -n, questions return their defaults without prompting — exactly the behaviour CI needs, and the reason the confirmation above defaults to "no".

Cleo in the terminal Terminal session showing a Cleo greeting command, a typo with a suggestion, and a migration declined automatically in no-interaction mode. Cleo in the terminal bash $ greeter greet Ann --yell HELLO ANN $ greeter gret The command "gret" does not exist. Did you mean this? greet $ greeter db migrate -n; echo "exit $?" | 0004_add_index | pending | aborted exit 1 With -n the confirmation returns its default, so the safe answer must be the default.

Arguments and options in more depth

Cleo's argument() and option() helpers cover the shapes most commands need, but the flags interact in ways worth knowing before you design a command.

from cleo.helpers import argument, option

arguments = [
    argument("paths", description="Files to process.", multiple=True),       # one or more
    argument("target", description="Where to write.", optional=True, default="out"),
]
options = [
    option("force", "f", description="Overwrite.", flag=True),               # --force / -f
    option("level", "l", description="0-9.", flag=False, default="6"),       # --level 9
    option("tag", None, description="Repeatable.", flag=False, multiple=True),  # --tag a --tag b
    option("color", None, description="Optional value.", flag=False, value_required=False),
]
  • flag=True (the default for option()) makes a boolean switch. Set flag=False for an option that takes a value — forgetting this is the most common Cleo mistake, and it shows up as "option does not accept a value".
  • multiple=True on an option collects repeated values into a list; on an argument it makes the argument variadic, and only the last argument may be variadic.
  • value_required=False creates an option whose value is optional: --color alone gives None, --color=auto gives "auto", and leaving it out gives the default. Useful, but harder to explain in help; prefer two clear options when you can.
  • Defaults are returned as given. default="6" comes back as the string "6", and a value from the command line is also a string. Convert in one place — a small helper on a base command class keeps every command consistent.

Command metadata is just as declarative. help holds a longer description shown by greeter help greet, aliases adds alternative names, and hidden = True keeps internal commands out of list while leaving them callable. Because everything is a class attribute, a base class can supply shared options — for example a --config path every command accepts — by extending options in subclasses.

UX considerations

  • Usage errors exit with 1, not 2. A missing option value or an unknown command makes Cleo print an error and return 1, the same status as a failed command. Validate values inside handle() and return 2 yourself for bad input, as GreetCommand does; if the distinction matters for the parser's own errors, wrap run() and test the behaviour you rely on. Choosing exit codes for CLI tools explains why scripts care.
  • Option values are strings. Cleo does not convert types; int(self.option("times")) is your job, and so is a friendly error when it fails.
  • "Did you mean" is built in. Mistyping gret prints "The command "gret" does not exist" followed by a suggestion of greet.
  • Destructive commands should honour -n. Because --no-interaction makes confirm() return its default, make that default the safe choice, and offer an explicit --yes if automation needs to proceed. See adding dry run and confirmation to destructive commands.
  • Keep data on stdout and chatter on stderr. line_error() for status messages keeps piped output clean, just as with any other framework.

Testing the behaviour

Cleo ships two testers. CommandTester runs one command with an argument string and optional simulated input; ApplicationTester runs the whole application, including global options and command lookup.

# tests/test_commands.py
from cleo.testers.application_tester import ApplicationTester
from cleo.testers.command_tester import CommandTester

from greeter.app import create_app


def command_tester(name: str) -> CommandTester:
    return CommandTester(create_app().find(name))


def test_greet_options():
    t = command_tester("greet")
    assert t.execute("Ann --yell --times 2") == 0
    assert t.io.fetch_output() == "HELLO ANN\nHELLO ANN\n"


def test_greet_rejects_non_numeric_times():
    t = command_tester("greet")
    assert t.execute("--times many") == 2
    assert "whole number" in t.io.fetch_error()


def test_migrate_declined_by_default():
    t = command_tester("db migrate")
    assert t.execute(inputs="\n") == 1
    assert "aborted" in t.io.fetch_error()


def test_migrate_confirmed():
    t = command_tester("db migrate")
    assert t.execute(inputs="yes\n") == 0
    assert t.io.fetch_output().endswith("applied 3 migrations\n")


def test_dry_run_prints_plan_without_asking():
    t = command_tester("db migrate")
    assert t.execute("--dry-run") == 0
    assert "0005_backfill" in t.io.fetch_output()


def test_verbose_flag_through_the_application():
    app = create_app()
    app.auto_exits(False)
    t = ApplicationTester(app)
    assert t.execute("greet Bo -v") == 0
    assert "greeted Bo 1x" in t.io.fetch_error()

Note the auto_exits(False) call: by default Application.run() calls sys.exit, which would end the test run. The testers capture both streams separately, so tests can assert that data and diagnostics land where they should. Interactive prompts write their question text to the error stream, which is why the "declined" test checks fetch_error().

Structuring a larger Cleo application

The class model pays off when an application has dozens of commands. Three habits keep it manageable. First, put shared behaviour — loading configuration, creating an API client, converting common options — on a base command, and create expensive services lazily on first use so greeter --version stays instant. Second, keep handle() short: parse and validate, call a plain function from your core package, and render the result; the core function is then testable without any Cleo machinery, the same separation recommended in dependency injection patterns for CLI commands. Third, register commands through a FactoryCommandLoader so that each command's module, and its imports, load only when that command runs. Poetry follows all three, which is why a tool with that many commands still starts quickly.

Conclusion

Cleo trades decorators for command classes and gives you a mature console layer in return: styled output, tables, progress, questions, verbosity levels and non-interactive mode, all consistent across commands. Declare arguments and options as class attributes, convert and validate values in handle(), return 2 for bad input yourself, and test with CommandTester and ApplicationTester. For applications with many namespaced commands — the kind Poetry is — it is a well-proven structure.

Frequently asked questions

Can I share setup code between Cleo commands?

Yes: create a base class that inherits from Command and provides helpers or lazily-created services, and have your commands inherit from it. Poetry does exactly this with its own base command. Keep the base class thin so commands stay readable.

How do I add shell completion?

Register Cleo's completions command — from cleo.commands.completions_command import CompletionsCommand and app.add(CompletionsCommand()) — then users run mytool completions bash and install the output. The general installation steps are in installing shell completion for bash, zsh and fish.

Does Cleo support lazy loading of commands?

Yes, through a command loader (FactoryCommandLoader) that maps names to factory functions, so a command's module is imported only when it runs. That keeps startup fast for large applications; see lazy-loading subcommands for faster startup.

Should I choose Cleo over Click for a new project?

Choose Cleo if the class-based model and its console layer fit how your team builds applications. Choose Click or Typer for the larger ecosystem — plugins, documentation generators and community answers. Both are capable of large applications.