Plain is headed towards 1.0! Subscribe for development updates →

CLI

The plain CLI loads commands from Plain itself, and any INSTALLED_PACKAGES.

Commands are written using Click) (one of Plain's few dependencies), which has been one of those most popular CLI frameworks in Python for a long time now.

Built-in commands

plain shell

Open a Python shell with the Plain loaded.

To auto-load models or run other code at shell launch, create an app/shell.py and it will be imported automatically.

# app/shell.py
from organizations.models import Organization

__all__ = [
    "Organization",
]

plain compile

Compile static assets (used in the deploy/production process).

Automatically runs plain tailwind compile if plain-tailwind is installed.

Automatically runs npm run compile if you have a package.json with scripts.compile.

plain run

Run a Python script in the context of your app.

plain preflight

Run preflight checks to ensure your app is ready to run.

plain create

Create a new local package.

plain setting

View the runtime value of a named setting.

Adding commands

Add an app/cli.py

You can add "root" commands to your app by defining a cli function in app/cli.py.

import click


@click.group()
def cli():
    pass


@cli.command()
def custom_command():
    click.echo("An app command!")

Then you can run the command with plain.

$ plain custom-command
An app command!

Add CLI commands to your local packages

Any package in INSTALLED_PACKAGES can define CLI commands by creating a cli.py in the root of the package. In cli.py, create a command or group of commands named cli.

import click


@click.group()
def cli():
    pass


@cli.command()
def hello():
    click.echo("Hello, world!")

Plain will use the name of the package in the CLI, then any commands you defined.

$ plain <pkg> hello
Hello, world!

Add CLI commands to published packages

Some packages, like plain-dev, never show up in INSTALLED_PACKAGES but still have CLI commands. These are detected via Python entry points.

An example with pyproject.toml and UV:

# pyproject.toml
[project.entry-points."plain.cli"]
"dev" = "plain.dev:cli"
"pre-commit" = "plain.dev.precommit:cli"
"contrib" = "plain.dev.contribute:cli"
 1import importlib
 2from importlib.metadata import entry_points
 3from importlib.util import find_spec
 4
 5import click
 6
 7from plain.packages import packages
 8
 9
10class InstalledPackagesGroup(click.Group):
11    """
12    Packages in INSTALLED_PACKAGES with a cli.py module
13    will be discovered automatically.
14    """
15
16    PLAIN_APPS_PREFIX = "plain."
17    MODULE_NAME = "cli"
18
19    def list_commands(self, ctx):
20        packages_with_commands = []
21
22        # Get installed packages with a cli.py module
23        for app in packages.get_package_configs():
24            if not find_spec(f"{app.name}.{self.MODULE_NAME}"):
25                continue
26
27            cli_name = app.name
28
29            if cli_name.startswith(self.PLAIN_APPS_PREFIX):
30                cli_name = cli_name[len(self.PLAIN_APPS_PREFIX) :]
31
32            packages_with_commands.append(cli_name)
33
34        return packages_with_commands
35
36    def get_command(self, ctx, name):
37        # Try it as plain.x and just x (we don't know ahead of time which it is, but prefer plain.x)
38        for n in [self.PLAIN_APPS_PREFIX + name, name]:
39            if not find_spec(n):
40                # plain.<name> doesn't exist at all
41                continue
42
43            if not find_spec(f"{n}.{self.MODULE_NAME}"):
44                continue
45
46            cli = importlib.import_module(f"{n}.{self.MODULE_NAME}")
47
48            # Get the app's cli.py group
49            try:
50                return cli.cli
51            except AttributeError:
52                continue
53
54
55class EntryPointGroup(click.Group):
56    """
57    Python packages can be added to the Plain CLI
58    via the plain_cli entrypoint in their setup.py.
59
60    This is intended for packages that don't go in INSTALLED_PACKAGES.
61    """
62
63    ENTRYPOINT_NAME = "plain.cli"
64
65    def list_commands(self, ctx):
66        rv = []
67
68        for entry_point in entry_points().select(group=self.ENTRYPOINT_NAME):
69            rv.append(entry_point.name)
70
71        rv.sort()
72        return rv
73
74    def get_command(self, ctx, name):
75        for entry_point in entry_points().select(group=self.ENTRYPOINT_NAME):
76            if entry_point.name == name:
77                return entry_point.load()