Architecture

Building Dynamic Commands in Click

Create Click commands at runtime: a custom Group with list_commands and get_command, commands from a directory of scripts, user aliases, lazy loading and tests.

Updated

Decorators are the usual way to define Click commands, and they assume you know every command when you write the code. Some CLIs do not. A team toolbox wants each script dropped into a directory to become a subcommand. Power users want to define their own aliases (mytool dp for mytool deploy --env prod). An API wrapper wants one command per endpoint in a schema that changes independently of the code. A large tool wants to import each command's module only when that command runs. All of these need commands that are created at runtime, and Click supports it directly: a group's contents are whatever its list_commands and get_command methods say they are. This guide builds a group that loads commands from a directory of scripts, adds user-defined aliases, keeps startup fast by loading only what is invoked, and tests it. It is one of the reasons to choose Click over Typer for some tools, discussed in the Typer vs Click topic.

Prerequisites

How Click asks a group for its commands

A click.Group answers two questions. list_commands(ctx) returns the names of available commands — used for --help and shell completion. get_command(ctx, name) returns a click.Command for one name, or None if it does not exist — used when the user invokes a command. The default implementation reads a dictionary filled by decorators; override the two methods and the commands can come from anywhere.

How Click asks a group for commands Click asks a custom group to list its command names for help, and to get one command by name when the user invokes it, so commands can be created on demand. How Click asks a group for commands Click Custom Group Command source list_commands(ctx) names only cheap get_command(ctx, "deploy") build one command click.Command Building only the command that was invoked keeps startup fast however many commands exist.

Because get_command is called with the single name the user typed, a group can build or import only that command. Listing is kept cheap — names only — so --help and completion stay fast however many commands exist.

The recipe: commands from a directory

Each file in a commands directory defines a Click command named cli. The group lists files for help, and imports a file only when its command is invoked:

# src/mytool/dynamic.py
from __future__ import annotations

import importlib.util
import tomllib
from pathlib import Path

import click


class DirectoryGroup(click.Group):
    """Built-in commands plus one command per *.py file in a directory, plus user aliases."""

    def __init__(self, *args, commands_dir: Path, aliases_file: Path | None = None, **kwargs):
        super().__init__(*args, **kwargs)
        self.commands_dir = commands_dir
        self.aliases_file = aliases_file

    # --- discovery -------------------------------------------------------------------------
    def _script_names(self) -> dict[str, Path]:
        if not self.commands_dir.is_dir():
            return {}
        return {p.stem.replace("_", "-"): p for p in self.commands_dir.glob("*.py")
                if not p.name.startswith("_")}

    def _aliases(self) -> dict[str, list[str]]:
        if not (self.aliases_file and self.aliases_file.exists()):
            return {}
        data = tomllib.loads(self.aliases_file.read_text(encoding="utf-8"))
        return {name: str(expansion).split() for name, expansion in data.get("aliases", {}).items()}

    def list_commands(self, ctx: click.Context) -> list[str]:
        names = set(super().list_commands(ctx)) | set(self._script_names()) | set(self._aliases())
        return sorted(names)

    # --- loading ---------------------------------------------------------------------------
    def get_command(self, ctx: click.Context, name: str) -> click.Command | None:
        builtin = super().get_command(ctx, name)          # built-ins always win
        if builtin is not None:
            return builtin
        script = self._script_names().get(name)
        if script is not None:
            return self._load_script(name, script)
        expansion = self._aliases().get(name)
        if expansion:
            return self._alias_command(ctx, name, expansion)
        return None

    def _load_script(self, name: str, path: Path) -> click.Command:
        spec = importlib.util.spec_from_file_location(f"mytool_user_commands.{path.stem}", path)
        module = importlib.util.module_from_spec(spec)
        try:
            spec.loader.exec_module(module)
            cmd = getattr(module, "cli")
        except Exception as exc:
            raise click.ClickException(f"command {name!r} from {path} failed to load: {exc}") from exc
        if not isinstance(cmd, click.Command):
            raise click.ClickException(f"{path} must define a click command named 'cli'")
        cmd.name = name
        return cmd

    def _alias_command(self, ctx: click.Context, name: str, expansion: list[str]) -> click.Command:
        target = super().get_command(ctx, expansion[0]) or self._script_names().get(expansion[0])
        if target is None:
            raise click.ClickException(f"alias {name!r} points at unknown command {expansion[0]!r}")

        @click.command(name=name, help=f"Alias for: {' '.join(expansion)}",
                       context_settings={"ignore_unknown_options": True})
        @click.argument("extra", nargs=-1, type=click.UNPROCESSED)
        @click.pass_context
        def alias(inner: click.Context, extra: tuple[str, ...]) -> None:
            args = expansion + list(extra)
            inner.parent.command.main(args=args, prog_name=inner.parent.info_name,
                                      standalone_mode=False, obj=inner.obj)

        return alias
# src/mytool/cli.py
from pathlib import Path

import click

from mytool.dynamic import DirectoryGroup

CONFIG = Path.home() / ".config" / "mytool"


@click.group(cls=DirectoryGroup, commands_dir=CONFIG / "commands", aliases_file=CONFIG / "aliases.toml")
def cli() -> None:
    """Team toolbox: built-in commands plus your own."""


@cli.command()
@click.argument("site")
@click.option("--env", type=click.Choice(["dev", "prod"]), default="dev")
def deploy(site: str, env: str) -> None:
    """Deploy SITE."""
    click.echo(f"deploying {site} to {env}")


if __name__ == "__main__":
    cli()

A user command is an ordinary Click command in a file:

# ~/.config/mytool/commands/rotate_logs.py
import click


@click.command()
@click.option("--keep", default=7, show_default=True, help="Days of logs to keep.")
def cli(keep: int) -> None:
    """Delete log files older than KEEP days."""
    click.echo(f"rotating logs, keeping {keep} days")

And aliases live in TOML:

# ~/.config/mytool/aliases.toml
[aliases]
dp = "deploy --env prod"
Where dynamic commands come from Sources a Click group can build commands from at runtime: a directory of scripts, a configuration file of aliases, installed plugins, or an API schema. Where dynamic commands come from DynamicGroup list_commands + get_command Scripts dir one file = one command Config aliases user shortcuts Plugins entry points API schema one op = one command every source ends up as an ordinary click.Command Downstream, dynamic commands behave exactly like decorated ones: help, completion, testing.

Why it is shaped this way

Built-ins win. A user script or alias named deploy could otherwise shadow the real command. Checking super().get_command first keeps the tool's own interface stable.

Only the invoked script is imported. list_commands reads file names; get_command executes one file. A directory with fifty scripts costs fifty stat calls for --help, not fifty imports. The same technique, applied to your own modules, is the basis of lazy-loading subcommands for faster startup.

Loading failures are user errors, not tracebacks. A syntax error in someone's script raises ClickException, which Click prints as a one-line error with exit status 1. The rest of the tool keeps working.

Aliases re-enter the group. An alias expands its words and calls the group's main again, so the expanded command goes through the same parsing, validation and help as if the user had typed it. ignore_unknown_options and UNPROCESSED pass any extra words through untouched (mytool dp web becomes deploy --env prod web).

UX considerations

Commands that appear from a directory Terminal output of a CLI listing commands discovered from a scripts directory and running one of them. Commands that appear from a directory bash $ ls ~/.config/mytool/commands/ backup.py rotate-logs.py $ mytool --help | sed -n "/Commands/,$p" Commands: backup deploy rotate-logs status $ mytool rotate-logs --keep 7 User-supplied commands get the same help and option parsing as built-in ones.
  • Show dynamic commands in help like any other. Because they are real click.Command objects, their docstrings become help text and their options are validated normally. Consider a separate help section for user commands so people can tell them apart from built-ins.
  • Tell users where commands come from. A mytool commands --where listing each command's source (built-in, script path, alias expansion) makes a directory-driven tool debuggable.
  • Keep completion working. Shell completion calls list_commands too, so dynamic commands complete automatically — one reason to keep listing cheap.
  • Treat the directory as trusted code. Scripts run with the tool's permissions. Load them only from a user-owned config directory, never from the current working directory, where a cloned repository could plant one.

Testing the behaviour

Point the group at temporary directories and invoke it with CliRunner:

# tests/test_dynamic.py
from pathlib import Path

import click
from click.testing import CliRunner

from mytool.dynamic import DirectoryGroup


def make_cli(tmp_path: Path) -> click.Group:
    @click.group(cls=DirectoryGroup, commands_dir=tmp_path / "commands",
                 aliases_file=tmp_path / "aliases.toml")
    def cli() -> None:
        """Test toolbox."""

    @cli.command()
    @click.argument("site")
    @click.option("--env", default="dev")
    def deploy(site: str, env: str) -> None:
        click.echo(f"deploying {site} to {env}")

    return cli


def write(path: Path, text: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(text, encoding="utf-8")


SCRIPT = 'import click\n@click.command()\n@click.option("--keep", default=7)\ndef cli(keep):\n    click.echo(f"keep={keep}")\n'


def test_script_becomes_a_command(tmp_path):
    write(tmp_path / "commands" / "rotate_logs.py", SCRIPT)
    runner = CliRunner()
    assert "rotate-logs" in runner.invoke(make_cli(tmp_path), ["--help"]).output
    assert runner.invoke(make_cli(tmp_path), ["rotate-logs", "--keep", "3"]).output == "keep=3\n"


def test_alias_expands_with_extra_args(tmp_path):
    write(tmp_path / "aliases.toml", '[aliases]\ndp = "deploy --env prod"\n')
    result = CliRunner().invoke(make_cli(tmp_path), ["dp", "web"])
    assert result.output == "deploying web to prod\n"


def test_broken_script_is_a_clean_error(tmp_path):
    write(tmp_path / "commands" / "broken.py", "this is not python\n")
    result = CliRunner().invoke(make_cli(tmp_path), ["broken"])
    assert result.exit_code == 1 and "failed to load" in result.output


def test_builtins_cannot_be_shadowed(tmp_path):
    write(tmp_path / "commands" / "deploy.py", SCRIPT)
    result = CliRunner().invoke(make_cli(tmp_path), ["deploy", "web"])
    assert result.output == "deploying web to dev\n"

Building the group inside a factory for each test keeps tests independent, since each points at its own temporary directory. More patterns are in testing Click commands with CliRunner.

Conclusion

Click groups are not fixed lists: override list_commands and get_command and commands can come from a scripts directory, a config file of aliases, installed plugins or an API schema, created only when invoked. Keep listing cheap, let built-ins win name clashes, turn loading failures into clean errors, route aliases back through normal parsing, and test with temporary directories. Dynamic commands then look and behave exactly like decorated ones — help, validation, completion and all.

Frequently asked questions

Can I do this in Typer?

Yes, with a Typer-native group: subclass typer.core.TyperGroup, override list_commands and get_command exactly as above, and pass it with typer.Typer(cls=YourGroup). Commands you return must be Typer-built — create a small typer.Typer() for each and convert it with typer.main.get_command(...). Recent Typer releases ship their own vendored copy of Click, so objects from the standalone click package and Typer's objects are different classes and cannot be mixed in one command tree; keep a given tree entirely in one or the other.

How do I generate commands from an OpenAPI schema?

In get_command, look up the operation by name and build a click.Command whose params are created from the operation's parameters (click.Option([f"--{p['name']}"], required=p["required"])) and whose callback performs the request. Cache the parsed schema so listing stays fast.

How is this different from entry-point plugins?

Entry points discover commands from installed packages; this discovers them from files and configuration. Both can live in the same get_command, and entry-point loading is covered in discovering plugins with entry points.

How do I cache expensive command discovery?

If listing requires real work — parsing a large schema, querying an API — cache the list of names in the tool's cache directory, keyed by the schema's hash or a short time-to-live, and rebuild it in the background or on a refresh command. Listing must stay fast because help and completion call it constantly. The general approach is in caching expensive work between CLI runs.

Do dynamic commands work with shell completion?

Yes. Completion calls list_commands for command names and then the resolved command's parameters for options, so dynamic commands complete like any other — provided listing does not import heavy modules.