Multicall binariesยถ

Some of the oldest tools on Unix are one file answering to many names: bzip2, bunzip2 and bzcat are the same binary, vim and view differ by default options, and BusyBox multiplexes hundreds of applets behind symlinks pointing at a single executable. The behavior is selected by the invocation name: the argv[0] the process starts under, which a symlink or a hard link is free to change without touching the file itself.

MulticallGroup brings the pattern to Click Extra: a group that, when invoked under the name of one of its subcommands, skips the group entirely and behaves exactly like that subcommand as a standalone binary. Neither Click nor Cloup ships anything comparable, and on the Rust side clap has had first-class multicall since 3.2, which this design borrows from.

Declaring a multicall groupยถ

Use multicall_group() in place of group(); subcommands are declared the usual way:

from click_extra import argument, echo, multicall_group, option

@multicall_group()
def kitchen():
    """A multicall kitchen appliance."""

@kitchen.command()
@option("--temperature", default="180")
@argument("dishes", nargs=-1)
def bake(temperature, dishes):
    """Bake dishes in the oven."""
    echo(f"Baking at {temperature} degrees: {', '.join(dishes) or 'nothing'}.")

@kitchen.command()
@option("--hours", default="2")
@argument("bottles", nargs=-1)
def chill(hours, bottles):
    """Chill bottles in the fridge."""
    echo(f"Chilling for {hours} hours: {', '.join(bottles) or 'nothing'}.")

Invoked under its own name, the CLI is a regular group:

$ kitchen --help
Usage: kitchen [OPTIONS] COMMAND [ARGS]...

  A multicall kitchen appliance.

Options:
  --time / --no-time           Measure and print elapsed execution time.
                               [default: no-time]
  --config CONFIG_PATH         Location of the configuration file. Supports
                               local path with glob patterns or remote URL.
                               [default: ~/.config/kitchen/{*.toml,*.yaml,*.yml,
                               *.json,*.json5,*.jsonc,*.hjson,*.ini,*.xml,*.plis
                               t,*.sqlite,*.sqlite3,*.conf,pyproject.toml}]
  --no-config                  Ignore all configuration files and only use
                               command line parameters and environment
                               variables.
  --validate-config FILE       Validate the configuration file and exit.
  --export-config FORMAT       Export the configuration in the selected format
                               to <stdout>, then exit.
  --accessible                 Accessibility mode: disable colors and render
                               tables in a borderless, screen-reader-friendly
                               format.
  --color [auto|always|never]  Colorize the output. A bare --color is the same
                               as --color=always.  [default: auto]
  --no-color                   Disable colorization (alias of --color=never).
  --progress / --no-progress   Show progress indicators during long operations.
                               Disabled for non-interactive output (pipes, dumb
                               terminals, CI) and by --accessible.  [default:
                               progress]
  --theme [auto|dark|dracula|light|manpage|monokai|nord|solarized_dark]
                               Color theme used for help screens.  [default:
                               dark]
  --params                     Show all CLI parameters, their provenance,
                               defaults and value, then exit.
  --table-format [aligned|asciidoc|colon-grid|csv|csv-excel|csv-excel-tab|csv-unix|double-grid|double-outline|fancy-grid|fancy-outline|github|grid|heavy-grid|heavy-outline|hjson|html|jira|json|json5|jsonc|latex|latex-booktabs|latex-longtable|latex-raw|mediawiki|mixed-grid|mixed-outline|moinmoin|orgtbl|outline|pipe|plain|presto|pretty|psql|rounded-grid|rounded-outline|rst|simple|simple-grid|simple-outline|textile|toml|tsv|unsafehtml|vertical|xml|yaml|youtrack]
                               Rendering style of tables.  [default: rounded-
                               outline]
  --verbosity LEVEL            Either CRITICAL, ERROR, WARNING, INFO, DEBUG.
                               [default: WARNING]
  -v, --verbose                Increase the default WARNING verbosity by one
                               level for each additional repetition of the
                               option.  [default: 0]
  -q, --quiet                  Decrease the default WARNING verbosity by one
                               level for each additional repetition of the
                               option.  [default: 0]
  --tree                       Show the tree of nested subcommands and exit.
  --man                        Read the command's manual page and exit.
  --help-format [carapace|json|json-full|man|markdown|markdown-full]
                               Render the command in the given format and exit.
  --version                    Show the version and exit.
  -h, --help                   Show this message and exit.

Commands:
  bake           Bake dishes in the oven.
  chill          Chill bottles in the fridge.
  help           Show help for a command.
  personalities  List the invocation names this binary answers to.
$ kitchen bake --temperature 200 pie
Baking at 200 degrees: pie.

Invoked under the name of one of its subcommands, it is that subcommand. CliRunner simulates the invocation name with its prog_name parameter, which stands in for the symlink below:

$ bake --help
Usage: bake [OPTIONS] [DISHES]...

  Bake dishes in the oven.

Options:
  --temperature TEXT           [default: 180]
  --time / --no-time           Measure and print elapsed execution time.
                               [default: no-time]
  --config CONFIG_PATH         Location of the configuration file. Supports
                               local path with glob patterns or remote URL.
                               [default: ~/.config/bake/{*.toml,*.yaml,*.yml,*.j
                               son,*.json5,*.jsonc,*.hjson,*.ini,*.xml,*.plist,*
                               .sqlite,*.sqlite3,*.conf,pyproject.toml}]
  --no-config                  Ignore all configuration files and only use
                               command line parameters and environment
                               variables.
  --validate-config FILE       Validate the configuration file and exit.
  --export-config FORMAT       Export the configuration in the selected format
                               to <stdout>, then exit.
  --accessible                 Accessibility mode: disable colors and render
                               tables in a borderless, screen-reader-friendly
                               format.
  --color [auto|always|never]  Colorize the output. A bare --color is the same
                               as --color=always.  [default: auto]
  --no-color                   Disable colorization (alias of --color=never).
  --progress / --no-progress   Show progress indicators during long operations.
                               Disabled for non-interactive output (pipes, dumb
                               terminals, CI) and by --accessible.  [default:
                               progress]
  --theme [auto|dark|dracula|light|manpage|monokai|nord|solarized_dark]
                               Color theme used for help screens.  [default:
                               dark]
  --params                     Show all CLI parameters, their provenance,
                               defaults and value, then exit.
  --table-format [aligned|asciidoc|colon-grid|csv|csv-excel|csv-excel-tab|csv-unix|double-grid|double-outline|fancy-grid|fancy-outline|github|grid|heavy-grid|heavy-outline|hjson|html|jira|json|json5|jsonc|latex|latex-booktabs|latex-longtable|latex-raw|mediawiki|mixed-grid|mixed-outline|moinmoin|orgtbl|outline|pipe|plain|presto|pretty|psql|rounded-grid|rounded-outline|rst|simple|simple-grid|simple-outline|textile|toml|tsv|unsafehtml|vertical|xml|yaml|youtrack]
                               Rendering style of tables.  [default: rounded-
                               outline]
  --verbosity LEVEL            Either CRITICAL, ERROR, WARNING, INFO, DEBUG.
                               [default: WARNING]
  -v, --verbose                Increase the default WARNING verbosity by one
                               level for each additional repetition of the
                               option.  [default: 0]
  -q, --quiet                  Decrease the default WARNING verbosity by one
                               level for each additional repetition of the
                               option.  [default: 0]
  --tree                       Show the tree of nested subcommands and exit.
  --man                        Read the command's manual page and exit.
  --help-format [carapace|json|json-full|man|markdown|markdown-full]
                               Render the command in the given format and exit.
  --version                    Show the version and exit.
  -h, --help                   Show this message and exit.

The personality is not the subcommand wearing a different name: it is a standalone command carrying the groupโ€™s options merged into the subcommandโ€™s. All of them parse in one flat pass, in any order, with no subcommand token in sight:

$ bake --verbosity INFO --temperature 200 pie
Baking at 200 degrees: pie.
$ bake pie --temperature 200
Baking at 200 degrees: pie.
$ chill
Chilling for 2 hours: nothing.

On a real system the name comes from argv[0]: create a symlink (or a hard link) next to the entry point, on your $PATH, bearing the subcommandโ€™s name:

ln -s "$(which kitchen)" "$(dirname "$(which kitchen)")/bake"
bake --temperature 200 pie

An invocation name matching no personality is not an error: it falls through to regular group behavior. That is also what keeps the feature inert under test runners and interpreters, where argv[0] is the runnerโ€™s own binary. On Windows, entry points are .exe shims and the suffix is stripped before matching, so a bake.exe wrapper still dispatches.

Personalities with preset optionsยถ

A personality maps to a sequence of tokens, not just a subcommand: the first token names the subcommand, and the rest is prepended to the userโ€™s arguments. That is how bzcat can be bzip2 --decompress --stdout, or view a vim that starts read-only:

from click_extra import argument, echo, multicall_group, option

@multicall_group(personalities={"quick-chill": ("chill", "--hours", "1")})
def fridge():
    """Multicall fridge with a preset personality."""

@fridge.command()
@option("--hours", default="2")
@argument("bottles", nargs=-1)
def chill(hours, bottles):
    """Chill bottles in the fridge."""
    echo(f"Chilling for {hours} hours: {', '.join(bottles) or 'nothing'}.")
$ quick-chill sparkling-water
Chilling for 1 hours: sparkling-water.

An explicit personalities mapping is exhaustive: only the names it declares dispatch, and the identity mapping every subcommand would otherwise get is dropped. Declare them all if you want them all.

Listing the personalitiesยถ

The auto-injected personalities subcommand enumerates every name the binary answers to, next to the command line each one invokes:

$ fridge personalities
โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ Name        โ”‚ Invokes         โ”‚ Description                  โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ quick-chill โ”‚ chill --hours 1 โ”‚ Chill bottles in the fridge. โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ

It renders through the usual table machinery, so --table-format applies. Pass personalities_command=False to @multicall_group() to suppress it, or register your own personalities subcommand to replace it.

The invocation-name hookยถ

For custom dispatch logic that does not fit the group machinery, every command exposes the name it was invoked under in ctx.meta, under the click_extra.context.INVOCATION_NAME key. It equals Clickโ€™s root info_name: the entry-point script name, the symlink name in personality mode, or an explicit prog_name override:

from click_extra import command, context, echo, pass_context

@command
@pass_context
def appliance(ctx):
    """Adapt its behavior to the name it was invoked under."""
    invoked_as = ctx.meta[context.INVOCATION_NAME]
    if invoked_as == "toaster":
        echo("Browning bread.")
    else:
        echo(f"Running as {invoked_as}.")
$ toaster
Browning bread.
$ appliance
Running as appliance.

Behavior notesยถ

A personality is a genuine standalone binary, with the consequences that follow:

  • The groupโ€™s callback does not run. The personality has no parent context. A group whose callback performs setup must fold that setup into the subcommands, or stay a plain group.

  • Namespaces follow the personality name. Configuration is read from the personalityโ€™s own app dir (a bake invocation reads what a standalone bake binary would, not the groupโ€™s kitchen directory), and the auto-generated environment variables carry the personality prefix (BAKE_TEMPERATURE). A kitchen bake invocation keeps the groupโ€™s namespaces.

  • Completion is keyed on the program name. Each personality needs its own shell-completion registration (a _{NAME}_COMPLETE variable, or its own carapace spec); the groupโ€™s spec does not cover them.

  • Eager options answer to the personality. --help, --version, --params, --man and their siblings render for the personality alone: bake --version names bake.