Building a Custom CLI Entry Point
This tutorial walks you through building a "Meta-CLI"—a command-line interface capable of dynamically discovering and executing other Typer applications. This is the same architecture used by the typer command itself to run scripts without requiring them to be installed as packages.
By the end of this guide, you will understand how to use State to track execution context and TyperCLIGroup to inject commands at runtime.
Prerequisites
To follow this tutorial, you need the typer package installed. This implementation relies on internal CLI tooling found in typer.cli.
Step 1: Initialize the State Container
The State class acts as a central registry for the CLI's current target. It stores information about which file or module is being inspected and which specific Typer app or function should be invoked.
In typer/cli.py, the state is initialized as a global singleton:
from pathlib import Path
from typer.cli import State
# Initialize the state container
state = State()
# State attributes we will use:
# state.app: str | None (The name of the Typer variable)
# state.file: Path | None (The path to the script)
# state.module: str | None (The name of the Python module)
Step 2: Create the Dynamic Command Group
To allow the CLI to "discover" commands inside a target script, you must use TyperCLIGroup. This class extends the standard TyperGroup to intercept Click's command resolution process.
When you try to run a command (like run), TyperCLIGroup checks the State and dynamically adds the command to the CLI if it finds a valid Typer app in the target file.
import typer
from typer.cli import TyperCLIGroup
app = typer.Typer()
# Use TyperCLIGroup as the custom class for the callback
@app.callback(cls=TyperCLIGroup, no_args_is_help=True)
def main_callback(
ctx: typer.Context,
path_or_module: str = typer.Argument(None),
app_name: str = typer.Option(None, "--app", help="The typer app object to use."),
):
"""
A Meta-CLI that loads other Typer apps.
"""
# We will update state here in the next step
pass
Step 3: Update State from Context
The maybe_update_state function extracts parameters from the Click context and populates the State object. This is crucial because TyperCLIGroup relies on this state to know which file to import.
Add the state update logic to your callback:
from typer.cli import maybe_update_state
@app.callback(cls=TyperCLIGroup, no_args_is_help=True)
def main_callback(
ctx: typer.Context,
path_or_module: str = typer.Argument(None),
app: str = typer.Option(None),
func: str = typer.Option(None),
):
# This populates the global state object in typer.cli
maybe_update_state(ctx)
Behind the scenes, maybe_update_state performs validation to determine if the input is a file path or a module name:
# Internal logic of maybe_update_state in typer/cli.py
file_path = Path(path_or_module)
if file_path.exists() and file_path.is_file():
state.file = file_path
else:
state.module = path_or_module
Step 4: Dynamic Command Injection
When you run your CLI, TyperCLIGroup triggers maybe_add_run. This function uses importlib to load the target script and searches for a typer.Typer instance.
If a valid app is found, it is converted into a Click command and injected into your CLI under the name run.
# This happens automatically inside TyperCLIGroup.invoke()
# or TyperCLIGroup.get_command()
def maybe_add_run(self, ctx: click.Context) -> None:
maybe_update_state(ctx)
maybe_add_run_to_cli(self)
The maybe_add_run_to_cli function (from typer/cli.py) performs the heavy lifting:
- It calls
get_typer_from_state(). - It uses
typer.main.get_command(obj)to convert the discovered Typer app into a Click object. - It renames that object to
"run"and adds it to the current CLI group.
Step 5: The Complete Result
Combining these components creates a CLI that can execute any Typer script. Here is the complete implementation based on typer/cli.py:
import typer
from typer.cli import TyperCLIGroup, maybe_update_state
app = typer.Typer()
@app.callback(cls=TyperCLIGroup, no_args_is_help=True)
def main(
ctx: typer.Context,
path_or_module: str = typer.Argument(None),
app: str = typer.Option(None, help="The typer app object/variable to use."),
func: str = typer.Option(None, help="The function to convert to Typer."),
):
"""
Run Typer scripts dynamically.
"""
maybe_update_state(ctx)
if __name__ == "__main__":
app()
Verifying the Result
You can now use this CLI to run a separate script. For example, if you have a file named my_script.py:
# my_script.py
import typer
app = typer.Typer()
@app.command()
def hello():
print("Hello from the dynamic app!")
You can execute it using your custom entry point:
$ python my_custom_cli.py my_script.py run hello
Hello from the dynamic app!
Next Steps
- Explore
typer.cli.get_typer_from_moduleto see the priority order for discovering apps (it checks names likeapp,cli, andmainby default). - Use
utils_appfromtyper.clito add helper commands likedocsto your custom CLI.