CLI integration

Plugins get three hooks into the CLI: tn-venv --list-plugins, HELP_EPILOG for customising --help, and the historical “monkey-patch cli_run for subcommands” pattern. This page covers all three.

tn-venv --list-plugins

The flag loads every plugin via load_plugins() and prints a table. The output has three columns: plugin name, comma-separated hook list, and provenance (built-in / entry-point: NAME=SPEC / env: VAR).

Example:

$ tn-venv --list-plugins
==> discovering plugins…
  version_stamp      hooks: post_activators
                     source: built-in: VersionStampPlugin

Plugins whose register() ran without calling hooks.add show up as (no hooks) so they’re not silently invisible. The plugin name comes from Plugin.name (or the class name if you don’t set it). The hook list comes from HookRegistry’s internal listener map. The source comes from the private Plugin._tn_venv_source attribute set by load_plugins — see Authoring for the dataclass.

Implementation details:

  • The flag is cli_only=True — it short-circuits before option resolution. You cannot combine it with --config or any config-file lookup. This is intentional: --list-plugins should always print something, regardless of how broken the user’s config file is.

  • The flag itself prints to stdout. Warnings (e.g. a plugin failed to import) print to stderr. They never go to the same stream; you can grep tn-venv --list-plugins cleanly.

  • --list-plugins returns 0 if any plugin loaded, 1 if none did. “None” means no plugins at all, including the built-ins, which is unusual but possible in a hostile environment.

HELP_EPILOG — customising tn-venv --help

HELP_EPILOG is the public way for plugins to append text to tn-venv --help output. The listener takes no arguments and returns a str (or None to opt out). Multiple HELP_EPILOG listeners are concatenated with blank-line separators and appear after the auto-generated Plugins: block:

$ tn-venv --help
... argparse output ...

configuration precedence: ...

examples:
  tn-venv                       ...

Plugins:                    ← auto-generated
  plugin_a        hooks: ...
  plugin_b        hooks: ...

extras from plugin_c:       ← HELP_EPILOG contribution
  mycmd    run my custom command

Run 'tn-venv help plugin_c' for details on its subcommands.

To register:

from tn_venv.plugins import HookName, Plugin


class HelpAdder(Plugin):
    name = "help_adder"

    def register(self, hooks):
        def _epilog():
            return (
                "extras from help_adder:\n"
                "  mycmd    run my custom command\n"
            )
        hooks.add(HookName.HELP_EPILOG, _epilog, plugin_name=self.name)

Rules:

  • The listener must return a str (or None to opt out). Empty string is treated as opt-out.

  • tn-venv always appends the auto-generated Plugins: block first. HELP_EPILOG is only for extra text the plugin wants to advertise (subcommand menus, examples, status pages, etc.).

  • Listener failures are caught and reported at warning level; they never break --help.

  • Plugins are loaded eagerly at the top of cli_run (not just when --help is requested). The eager load is what lets a plugin that wraps cli_run (e.g. tn-venv-gui) intercept argv like tn-venv gui --help before argparse sees the help flag. Cost is small — only the built-in plugin is loaded in the common case.

The legacy way to do this was to monkey-patch tn_venv.cli.build_parser and append to parser.epilog. New plugins should prefer HELP_EPILOG — it’s stable, public, and doesn’t depend on argparse internals.

Adding a CLI subcommand

The clean way to add tn-venv gui ... (or any other subcommand) is:

  1. Add a HELP_EPILOG listener that documents the subcommand in tn-venv --help output.

  2. In register(), monkey-patch tn_venv.cli.cli_run to dispatch the subcommand before argparse sees the argv.

The tn-venv-gui package is the canonical example. Its GuiSubcommandPlugin.register() does both:

def register(self, hooks):
    if os.environ.get("TN_VENV_GUI_NO_AUTOLOAD"):
        return
    # 1. Install the subcommand dispatch — wraps tn_venv.cli.cli_run.
    subcommand.install_subcommand()
    # 2. Contribute to --help output via the public hook.
    hooks.add(HookName.HELP_EPILOG, _gui_help_epilog, plugin_name=self.name)

The install_subcommand() function does the wrapper install. The mechanism:

  1. install_subcommand() saves a reference to the current tn_venv.cli.cli_run (let’s call it _ORIGINAL).

  2. It installs a _patched function as the new tn_venv.cli.cli_run.

  3. _patched inspects sys.argv[1:] (or the explicit args argument). If argv[0] is a known subcommand name (gui), it dispatches to the subcommand’s handler. Otherwise it delegates to _ORIGINAL.

  4. The dispatch is idempotent: subsequent invocations are no-ops, so calling install_subcommand() from register() (which runs every time load_plugins() is called) doesn’t double-wrap.

The wrapper pattern works because of two design choices in tn-venv itself:

  • cli_run is loaded eagerly at the top of cli_run itself, before argparse sees the argv. So the wrapper is installed before argparse parses tn-venv gui --help.

  • main() (the console-script entry point) looks cli_run up on its own module at call time, so a plugin’s monkey-patch is honoured even when main was imported before the patch was applied.

The catch is that the wrapper patch only ever runs on the next invocation. Inside the invocation where the patch installs itself, cli_run is already executing the original function frame; cli_run handles that by capturing the original cli_run reference at function entry (original_cli_run) and re-invoking through the (now-patched) module attribute if _self.cli_run is not original_cli_run. So tn-venv gui --help works on the first invocation, not just subsequent ones.

Why this is transitional

Monkey-patching tn_venv.cli.cli_run couples your plugin to argparse internals and to the structure of the existing CLI. We keep it as a supported path because it’s the simplest way to add subcommands today, but a future release may add a more structured “subcommand” registration. If you can avoid the monkey-patch (e.g. by relying on HELP_EPILOG alone), do.

A minimal subcommand plugin

"""A minimal ``tn-venv hello`` subcommand."""

from __future__ import annotations

import sys
from typing import Sequence

from tn_venv.plugins import HookName, Plugin


HELLO_EPILOG = """

text: hello world subcommand

Run 'tn-venv hello --help' for details.
"""


def _hello(args: Sequence[str]) -> int:
    if "--help" in args or "-h" in args:
        sys.stdout.write(HELLO_EPILOG)
        return 0
    sys.stdout.write("hello from tn-venv!\n")
    return 0


class HelloPlugin(Plugin):
    name = "hello"

    def register(self, hooks) -> None:
        # Document the subcommand in --help output.
        hooks.add(HookName.HELP_EPILOG, lambda: HELLO_EPILOG, plugin_name=self.name)

        # Install the subcommand dispatcher.
        try:
            from tn_venv import cli as _cli
            if getattr(_cli.cli_run, "_hello_installed", False):
                return
            original = _cli.cli_run

            def _patched(args=None, *, environ=None, **kw):
                argv = list(args) if args is not None else sys.argv[1:]
                if argv and argv[0] == "hello":
                    return _hello(argv[1:])
                return original(args, environ=environ, **kw)

            _patched._hello_installed = True  # type: ignore[attr-defined]
            _cli.cli_run = _patched
        except Exception:
            # Plugin loading must never raise.
            pass

This is intentionally small — it’s the “hello world” of CLI subcommands, not a template for production use. For a real subcommand, follow the patterns in tn-venv-gui.

Subcommand best practices

  • Idempotent installation. Your monkey-patch must detect a previously-installed version (via a marker attribute on the wrapper) and no-op.

  • Help handling. When the user passes --help or -h after the subcommand name, print your subcommand’s help and return 0 without falling through to the original CLI.

  • Pass through unknown argv. If argv doesn’t start with your subcommand name, delegate to the original cli_run. Don’t swallow args you don’t recognise.

  • Exit codes. Return 0 on success, non-zero on failure. Match the existing CLI’s exit code conventions (see Exit codes).

  • Respect *_NO_AUTOLOAD env vars. The tn-venv-gui package honours TN_VENV_GUI_NO_AUTOLOAD=1. Pick a similar name for your own no-op switch and document it.

What if my plugin needs a new CLI flag?

It can’t, and shouldn’t try. --flag and --no-flag live in tn_venv.config.spec. To add a new flag, send a PR to tn-venv itself. Adding CLI flags through a plugin would break --dry-run output, config-file mapping, env-var mapping, and every other piece of option plumbing — none of which are plugin-aware.

If you really need a one-off CLI flag for an internal tool, consider using tn-venv --with PKG plus a seeder plugin instead. Seeders are a separate extension mechanism — see Extending tn-venv.