Architecture

Writing a Plugin for an Existing CLI

Extend someone else's Python CLI - declare an entry point, add commands from your own package, version the contract, and test against the host.

Updated

Most of the writing about plugin systems is aimed at the host. This is the other side: you have a CLI you do not own, it advertises an extension point, and you want to add commands to it without forking anything.

TL;DR

  • A plugin is an ordinary package whose metadata names the host's entry-point group.
  • Installing it is the registration — the host reads installed metadata at run time.
  • Depend on the host with a version range, and declare whatever API version it checks.
  • Use only documented seams; importing the host's private modules is how plugins break.
  • Install it into the host's environment with pipx inject or uv tool install --with.

Prerequisites

A host CLI that documents an entry-point group (for example mytool.plugins), and a package of your own to put the commands in.

What a plugin package looks like

What a plugin package contains The layout of a plugin package for an existing CLI: a pyproject declaring the entry point, a module providing the commands, and tests against the host. What a plugin package contains mytool-deploy/ its own package and release cycle pyproject.toml declares the entry point src/mytool_deploy/cli.py the commands it adds tests/ invoked through the host CLI installing the package is the registration step — nothing in the host changes A plugin is an ordinary Python package whose metadata happens to name a group the host reads.
# pyproject.toml
[project]
name = "mytool-deploy"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["mytool>=1.4,<2.0"]      # the host, with a range

[project.entry-points."mytool.plugins"]
deploy = "mytool_deploy.cli:plugin"
# src/mytool_deploy/cli.py
import typer

API_VERSION = 2            # the contract version the host checks
app = typer.Typer(help="Deployment commands.")

@app.command()
def rollout(environment: str, replicas: int = 3) -> None:
    """Roll out the current build to ENVIRONMENT."""
    ...

class plugin:              # the object the entry point names
    api_version = API_VERSION
    name = "deploy"

    @staticmethod
    def register(host_app: typer.Typer) -> None:
        host_app.add_typer(app, name="deploy")

The exact shape of the object depends on the host's contract — some want a Typer app, some a callable, some a class with a register method. What is universal is that the entry point names an importable target and the host decides what to do with it.

Installing into the host's environment

From pip install to a working command Installing a plugin writes its entry point into the environment metadata, where the host CLI discovers and loads it on the next invocation. From pip install to a working command You Installer Metadata Host CLI pipx inject mytool mytool-deploy record the entry point mytool --help entry_points(group="mytool.plugins") Nothing is registered in code: the installed metadata is the registry.

A plugin must be installed where the host is, which for an isolated install means injecting into that environment rather than installing globally:

pipx inject mytool mytool-deploy               # into the host's own venv
uv tool install mytool --with mytool-deploy    # or declare it at install time
pipx inject mytool -e ./mytool-deploy          # editable, while developing

That last form is the development loop: an editable install means your edits take effect immediately, and mytool --help shows your commands straight away. If the plugin does not appear, the usual causes are installing it into the wrong environment, a typo in the group name, or a host that caches its plugin list.

Keeping the plugin working

Writing a plugin that keeps working Practices for authoring a command line plugin that survives host upgrades, and the habits that break on the next release. Writing a plugin that keeps working Survives upgrades Depend on the host with a version range Declare the API version the host checks Use only the host's documented extension points Test against the oldest supported host version Breaks on the next release Importing the host's private modules Monkeypatching host internals at import time Assuming a command name the host may rename Registering a name that shadows a built-in A plugin that only uses documented seams is one the host can refactor around.

Three habits decide whether your plugin survives the host's next release.

Depend on a range, not a pin. mytool>=1.4,<2.0 says what you actually support. A hard pin makes your plugin uninstallable alongside a slightly newer host; no constraint at all means it breaks silently when the host makes a major change.

Declare the API version the host asks for. If the host checks api_version, that integer is the contract. When it bumps, the host will skip your plugin with a message rather than half-loading it — which is exactly what you want, because a clear "needs API v3" is far better than an AttributeError in a user's terminal.

Use documented seams only. Importing mytool.internal.registry works today and is not part of anything the host promised. The same applies to monkeypatching host functions at import time, which turns your plugin into something the host cannot refactor around.

When you genuinely need something the host does not expose, ask for it upstream. A documented extension point benefits every plugin author, including future-you.

Designing the commands you add

Two conventions make a plugin feel like part of the tool rather than bolted on.

Namespace your commands unless the host says otherwise. mytool deploy rollout is unambiguous; a top-level mytool rollout competes with the host's own naming and with every other plugin. Most hosts that document a plugin group also document where plugin commands should live.

Follow the host's conventions for output, exit codes and configuration. If the host emits JSON under --json with a versioned envelope, your commands should too; if it uses exit code 78 for a configuration problem, use the same. Users do not care which package a command came from, and inconsistency reads as a bug in the tool.

Read configuration through whatever the host provides — usually a settings object on the context — rather than loading your own file. Two configuration systems in one CLI is a support burden that lands on the host's maintainer.

UX considerations

Name the package after the host. mytool-deploy tells a user immediately what it extends and groups your plugin with others in search results.

Document the injection command, not pip install. A user who installs your plugin globally while the host is in a pipx environment gets nothing, and the failure is silent — no error, just missing commands.

Say which host versions you support in the README, and keep it accurate. Users debug "my plugin disappeared" far faster when the answer is a version range they can check.

Fail helpfully. If your commands need credentials or a service the host does not, produce a clear error naming what is missing, exactly as the host would.

Testing the behaviour

The valuable test invokes the host and asserts your commands are reachable, because that exercises discovery rather than mocking it:

from typer.testing import CliRunner
from mytool.cli import app          # the host application

runner = CliRunner()

def test_plugin_commands_are_registered():
    result = runner.invoke(app, ["--help"])
    assert result.exit_code == 0
    assert "deploy" in result.stdout

def test_rollout_runs_through_the_host(monkeypatch):
    seen = {}
    monkeypatch.setattr("mytool_deploy.cli.core.rollout", lambda **kw: seen.update(kw))

    result = runner.invoke(app, ["deploy", "rollout", "prod", "--replicas", "5"])

    assert result.exit_code == 0
    assert seen == {"environment": "prod", "replicas": 5}

For that to work, the test environment needs both packages installed — the host as a dependency and your plugin editable, which uv sync gives you when the plugin is the project. Add the oldest supported host version to your CI matrix; that is the test that catches a contract change you would otherwise meet in a user's bug report.

Conclusion

A plugin is a small package with one line of metadata. The work is not in the mechanism but in the discipline around it: a version range, a declared API version, documented seams only, and conventions borrowed from the host. Do those four and your plugin keeps working through releases you were not part of.

Frequently asked questions

Why does the host not see my plugin?

Almost always the environment. An isolated host install has its own virtual environment, and a plugin installed elsewhere is invisible to it — use pipx inject or uv tool install --with. After that, check the group name character by character, and confirm the entry point resolves with python -c "from mytool_deploy.cli import plugin".

Can a plugin override a built-in command?

Most hosts refuse, and rightly. If yours allows it, do not: users expect a documented command to do what the documentation says, and a plugin silently replacing it is the hardest kind of bug to diagnose. Add a distinct command instead.

Should the plugin depend on the host at all?

Yes, with a range. You import its types and extension points, so the dependency is real, and declaring it means the resolver refuses an incompatible combination rather than letting a user discover it at run time.

How do I develop against an unreleased host version?

Install both editable in one environment: the host from a checkout, your plugin from yours. That is also the arrangement for contributing an extension point upstream — you can develop both sides in one loop and split the changes into two pull requests afterwards.

What if the host has no plugin system?

Then your options are a wrapper CLI that shells out, or a pull request adding an extension point. The wrapper is quick and fragile — it depends on the host's output format and flags — so if the host is maintained, proposing an entry-point group is usually the better investment.

How should a plugin be versioned relative to the host?

Independently. Your plugin has its own release cycle and its own semantic version; the relationship to the host is expressed by the dependency range, not by matching numbers. Matching versions looks tidy for one release and becomes a lie the moment either side needs a patch the other does not.

Can two plugins depend on incompatible versions of a shared library?

Yes, and it is the failure mode isolated installs make visible: pipx inject resolves both into one environment, so a genuine conflict surfaces at install time rather than at run time. That is the right place for it — but it means keeping your own dependency ranges wide enough to coexist with other plugins is part of being a good citizen.