Source code for pipecat.cli.main

#
# Copyright (c) 2025-2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#

"""Pipecat CLI entry point.

The CLI ships with ``pipecat-ai`` but its dependencies are optional (the ``cli``
extra). ``run`` is the console-script target (``pipecat`` / ``pc``): it degrades
gracefully, printing an install hint instead of raising ``ImportError`` when the
``cli`` extra is not installed.

The Typer app is built lazily (``_build_app``) so importing this module never pulls
in the optional CLI dependencies; the module-level ``app`` attribute (used by tests
and the console script) is resolved on first access via ``__getattr__``.
"""

import sys

_INSTALL_HINT = (
    "The Pipecat CLI needs its optional dependencies (the `cli` extra), which aren't "
    "installed.\n\n"
    "Install the extra wherever you want the `pipecat` command:\n\n"
    "  • As a global tool (on your PATH):\n"
    '        uv tool install "pipecat-ai[cli]"     # or: pipx install "pipecat-ai[cli]"\n\n'
    "  • In your current project or virtualenv:\n"
    '        uv pip install "pipecat-ai[cli]"      # or: pip install "pipecat-ai[cli]"\n'
)

# Official optional sub-CLIs. Each ships as a separate plugin package that registers
# a Typer app under the ``pipecat_cli.extensions`` group. We list them in ``--help``
# even when they're not installed (as stubs) so they're discoverable, and the stub
# prints how to enable the plugin. Only first-party plugins belong here.
_KNOWN_EXTENSIONS: dict[str, tuple[str, str]] = {
    "cloud": ("pipecatcloud", "Deploy and manage bots on Pipecat Cloud"),
}


def _enable_hint(name: str, package: str) -> str:
    """Message shown when an official-but-uninstalled sub-CLI is invoked."""
    return (
        f"The `pipecat {name}` command requires the optional `{package}` plugin, "
        "which isn't installed.\n\n"
        "Enable it where the `pipecat` command lives:\n\n"
        "  • As a global tool (on your PATH), reinstall with the plugin:\n"
        f'        uv tool install "pipecat-ai[cli]" --with {package}\n\n'
        "  • In your current project or virtualenv:\n"
        f"        uv pip install {package}     # or: pip install {package}\n"
    )


_app = None


def _build_app():
    """Build the Typer app, importing the optional CLI dependencies lazily."""
    import importlib.metadata as importlib_metadata

    import typer
    from rich.console import Console

    from pipecat.cli.commands.eval import eval_app
    from pipecat.cli.commands.init import init_command

    app = typer.Typer(
        name="pipecat",
        help="Command-line tools for building Pipecat AI applications.",
        add_completion=False,
    )
    console = Console()

    # `init` is the single entry point for building a Pipecat app: it writes the
    # coding-agent guide and can scaffold a runnable bot (interactively or from flags/a
    # config file, e.g. `pipecat init . --bot-type web -t daily ...`). No `help=` here so
    # the command's docstring (summary + examples) drives `pipecat init --help`.
    app.command("init")(init_command)

    # `pipecat create` was removed (folded into `init`). Keep a hidden stub so an old
    # command or muscle-memory invocation gets a clear pointer instead of Click's bare
    # "No such command". ignore_unknown_options swallows the old scaffolder flags.
    def _removed_create(ctx: typer.Context):
        print(
            "`pipecat create` was removed. Use `pipecat init` instead. It scaffolds "
            "a project from flags or a config file, e.g.\n\n"
            "    pipecat init . --bot-type web -t daily --stt deepgram_stt "
            "--llm openai_llm --tts cartesia_tts\n\n"
            "Run `pipecat init --help` or `pipecat init --list-options` for details.",
            file=sys.stderr,
        )
        raise typer.Exit(1)

    app.command(
        "create",
        help=(
            "The create command was removed. Use the init command to scaffold your "
            "Pipecat project. Run `pipecat init --help` for details."
        ),
        hidden=True,
        context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
    )(_removed_create)

    # `eval` is a first-party sub-Typer group, built in (not a plugin extension).
    app.add_typer(eval_app, name="eval")

    # Discover CLI extensions (e.g. `cloud` from pipecatcloud). The entry-point
    # group is intentionally still named
    # "pipecat_cli.extensions" for backward compatibility — renaming it would force
    # every plugin to re-release. (A future rename to "pipecat.cli.extensions" is a
    # separate, coordinated change.)
    extensions = [
        (ep.name, ep.load())
        for ep in importlib_metadata.entry_points(group="pipecat_cli.extensions")
    ]
    extensions.sort(key=lambda item: item[0].lower())
    for name, extension in extensions:
        app.add_typer(extension, name=name)

    # For official plugins that aren't installed, register a discoverable stub so the
    # command still shows up in `pipecat --help` and, when invoked, prints how to enable
    # it instead of a bare "No such command". Installed plugins (above) take precedence.
    def _make_extension_stub(cmd_name: str, package: str):
        def _stub(ctx: typer.Context):
            print(_enable_hint(cmd_name, package), file=sys.stderr)
            raise typer.Exit(1)

        return _stub

    installed = {name for name, _ in extensions}
    for cmd_name, (package, help_text) in sorted(_KNOWN_EXTENSIONS.items()):
        if cmd_name in installed:
            continue
        app.command(
            cmd_name,
            help=f"{help_text} (requires {package})",
            context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
        )(_make_extension_stub(cmd_name, package))

    def version_callback(value: bool):
        if value:
            try:
                pkg_version = importlib_metadata.version("pipecat-ai")
            except Exception:
                pkg_version = "unknown"
            console.print(f"ᓚᘏᗢ Pipecat CLI Version: [green]{pkg_version}[/green]")
            raise typer.Exit()

    @app.callback()
    def main(
        ctx: typer.Context,
        version_flag: bool = typer.Option(
            None,
            "--version",
            "-v",
            help="Show version and exit",
            callback=version_callback,
            is_eager=True,
        ),
    ):
        """Pipecat CLI - Build AI voice agents with ease."""

    return app


def __getattr__(name: str):
    # PEP 562: resolve `app` lazily so `from pipecat.cli.main import app` works without
    # importing typer at module load (keeps a bare, no-[cli] install import-safe).
    if name == "app":
        global _app
        if _app is None:
            _app = _build_app()
        return _app
    raise AttributeError(f"module {__name__!r} has no attribute {name!r}")


[docs] def run(): """Console-script entry point; degrades gracefully when the ``cli`` extra is absent.""" try: app = _build_app() except ImportError: print(_INSTALL_HINT, file=sys.stderr) raise SystemExit(1) app()
if __name__ == "__main__": run()