Skip to main content

Extending the command line

reactor is a Typer application built the way the reactor asks applications to build theirs: a host with a few commands of its own, plus whatever the extensions installed beside it contribute. Nothing in the host names an extension.

pip install datalayer_reactor
reactor --help
Commands
serve Serve a platform that runs whatever is installed beside it.
commands The commands plugins have registered — the palette, from a terminal.
extensions What is installed beside this reactor.
plugins Inspect and switch the plugins of a running reactor.

Install an extension and its commands are simply there:

pip install music-catalog-plugin
reactor --help # ... plus `catalog`
reactor catalog songs

Nothing was configured, and nothing on the host side mentions the package. Uninstall it and the group goes.

Running against a server

plugins talks to a reactor that is already serving, because switching a plugin off only means something in a process that is running one:

reactor plugins list # --url, default http://127.0.0.1:8787
reactor plugins disable cms.gallery
reactor plugins enable cms.gallery
reactor plugins deactivate cms.gallery
reactor plugins activate cms.gallery
🖼️ cms.gallery on active Gallery
🔎 cms.seo-validator on idle SEO Validator

Two independent facts, and a person needs both. on/off is a decision that sticks; active/idle is whether it is running right now. A plugin can be switched on and still be standing down, waiting for one of its activation events — and activate is how to say the reason has arrived without waiting for one.

deactivate stands dependants down first and the plugin comes back on its next activation event; disable is the switch a person threw.

Why Typer

Extensions are expected to share code with the host. A Typer sub-application is an ordinary object a plugin can build, test on its own, and hand over — and the host's help, completion and exit codes then cover it like its own commands.

An argparse host can be extended too, but only by handing plugins a mutable parser and hoping they agree about subparsers.

Writing a CLI extension

One hook, provide_cli. The host passes its application; the plugin adds what it ships:

class CatalogPlugin:
def provide_cli(self, cli) -> None:
import typer

catalog_app = typer.Typer(name="catalog", help="The song catalog.")

@catalog_app.command("songs")
def songs(artist: str = typer.Option(None, help="Only songs by this artist.")) -> None:
"""List the songs in the catalog."""
...

cli.add_typer(catalog_app)

Building the commands as a Typer of their own is what keeps them testable alone: catalog_app runs without any host at all.

The reactor stays framework-agnostic — it hands the object over, and the plugin knows what it is. A plugin that fails to register is skipped with a warning, never fatal: one broken extension must not take the whole command line down.

Being discovered

Declare an entry point in the distribution that ships the plugin. The entry point resolves to a callable returning (PluginManifest, implementation):

[project.entry-points."datalayer.reactor.cli"]
catalog = "catalog_plugin:plugin"
def plugin() -> tuple[PluginManifest, CatalogPlugin]:
return CATALOG_MANIFEST, CatalogPlugin()

Two groups are scanned, and which one to use is a question about the rest of the package:

GroupFor
datalayer.reactor.extensionsanything already shipping a UI or a backend — extensions declare this group anyway, so no second declaration is needed to also ship commands
datalayer.reactor.clia distribution that only extends the command line and has no reason to be loaded by a server

Commands on the command line

provide_cli and provide_slash_commands answer different questions, and both reach the CLI.

provide_cli is "what commands does this plugin add to the command line?" — resolved once, at startup, before anything runs.

provide_slash_commands is "what can somebody invoke in a session?" Those are the registry's commands, and reactor commands surfaces them:

reactor commands list
reactor commands run catalog.describe
🎵 catalog.describe Describe the catalog
🔎 cms.seo.rules Show the SEO rules
✨ cms.pro.rewrite Rewrite the selection

So the same command is reachable from a terminal and from Ctrl-K in the browser without being written twice. Keeping the two hooks separate is deliberate: a command group added to a CLI and a command invoked in a live session have different lifetimes — one is resolved once at startup, the other runs against a session that already exists.

Serving

reactor with no subcommand still serves, because it was a server before it was a command line and the shortest way to start one should not have got longer:

reactor # the same as `reactor serve`
reactor serve --port 9000

Hosting your own

reactor.cli is one application built from parts you can use for your own:

from reactor.cli import extend

app = typer.Typer(name="mytool")

@app.command()
def hello() -> None:
...

extend(app) # every installed extension adds its commands
app()

examples/cli is the smallest version of this — a host, a weather plugin, and no browser anywhere. See the CLI example.

The Datalayer CLI is the biggest: datalayer hosts its extensions through this exact machinery (extend over the datalayer.cli group), and the platform's own packages extend back — install datalayer_core or agent-runtimes beside the reactor command and their command groups (auth, secrets, sandboxes, agents, …) are simply there, advertised under datalayer.reactor.cli.

Interactive sessions

The same story for a prompt instead of a command line — an extensible REPL with a slash menu plugins fill — is The REPL.