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 injectoruv 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
# 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
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
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.