2. Adding a plugin¶
Plugins ship separately from the main CLI and contribute commands via
entry points. By the end of this page you'll have projectctl-deploy
installed and showing up as projectctl deploy.
Why a plugin and not just another commands/ file¶
Use a plugin when:
- The command ships on a different release cadence than the main CLI.
- A separate team owns it.
- You want it installable standalone (
pip install projectctl-deploywithout installing the whole project).
Use a local commands/ file when the command is part of this
project's lifecycle and versioning.
Scaffold the plugin¶
From the parent directory (not inside projectctl/):
Add the clickwork entry point¶
Edit projectctl-deploy/pyproject.toml and add the
clickwork.commands entry-point group:
Two things are happening here:
clickwork.commandsis the entry-point group — every clickwork CLI running in this Python environment will discover entry points registered under this group. There is no per-CLI scoping today; if you publish a command under this group in a venv that also has a sibling CLI, both CLIs see it. Design per-command names carefully to avoid collisions. For same-mechanism duplicates (two plugins both registering the same command name, or two local files producing the same Click command name), passstrict=Truetocreate_cli()— clickwork raisesClickworkDiscoveryErrorat CLI construction time. Notestrict=Truedoes NOT raise when a localcommands/file shadows an installed plugin: that's an intentional auto-mode feature, not a collision. (And don't rely onlogger.warningalone: clickwork attaches aNullHandlerat import and discovery runs during CLI construction, before most hosts have configured logging, so the messages often go unseen.)deploy = "projectctl_deploy:cli"says "expose adeploycommand whose Click object lives atprojectctl_deploy.cli". The command name on the command line comes from the entry-point key (deploy), not from the Click command's internal.name.
Write the command¶
Create projectctl-deploy/src/projectctl_deploy/__init__.py:
import click
@click.command()
@click.option("--env", default="staging", show_default=True,
help="Target environment.")
@click.option("--dry-run", is_flag=True, default=False,
help="Print what would happen without doing it.")
def cli(env: str, dry_run: bool) -> None:
"""Deploy the project to <env>."""
prefix = "[dry-run] " if dry_run else ""
click.echo(f"{prefix}Deploying to {env}...")
Install the plugin into the main CLI's venv¶
Back in the projectctl/ directory:
uv add with a local path installs in editable mode — edits to the
plugin reflect immediately.
Verify discovery¶
You should see:
And run it:
Expected:
Conflict handling: local wins¶
If a plugin ships a tail-logs command and you have
commands/tail_logs.py locally, the local file wins. Install-time
collisions never overwrite hand-maintained local commands. clickwork
emits an INFO log when a local file shadows an installed command
so stale local files don't silently hide plugin updates.
Next¶
In Packaging we'll build both projects as wheels and install them in a fresh venv to confirm the setup is reproducible.