Skip to content

Click CLI

The command-line counterpart of the FastAPI example: make a Click CLI extensible, so subcommands arrive with a plugin rather than by editing the command tree. The host owns one hook, register_commands, and each plugin attaches its own commands to the root group.

Full runnable example: examples/cookbook/cli_app.py.

The hook

import click
from pluginkit import Extension, ExtensionPoint, PluginManager

extension_point = ExtensionPoint("cli")
extension = Extension("cli")


class Specs:
    @staticmethod
    @extension_point
    def register_commands(cli: click.Group) -> None:
        """Attach subcommands to the root CLI group."""

A plugin

class GreetPlugin:
    @extension
    def register_commands(self, cli: click.Group) -> None:
        @cli.command()
        @click.argument("name")
        def greet(name: str) -> None:
            click.echo(f"hello {name}")

Assembling the CLI

The host builds the root group, registers its plugins, and fires the hook once so each plugin mounts its commands:

def build_cli(*plugins: object) -> click.Group:
    @click.group()
    def cli() -> None:
        """A CLI assembled entirely from plugins."""

    pm = PluginManager("cli")
    pm.add_extension_points(Specs)
    for plugin in plugins or (GreetPlugin(), VersionPlugin()):
        pm.register(plugin)
    pm.caller(Specs.register_commands)(cli=cli)
    return cli

Testing uses Click's CliRunner against an app built from chosen plugins:

from click.testing import CliRunner

def test_commands():
    cli = build_cli()
    runner = CliRunner()
    assert runner.invoke(cli, ["greet", "Ada"]).output.strip() == "hello Ada"

Swap pm.register(...) for pm.load_entrypoints("cli") and any installed package advertising the cli group contributes subcommands - the same entry-point discovery pattern the FastAPI example uses, applied to the command line.