DEV Community

Cover image for How to Build an Interactive Chat for Your Python CLI Using Introspection, Click, and Rich Formatting
Rodrigo Estrada
Rodrigo Estrada

Posted on

How to Build an Interactive Chat for Your Python CLI Using Introspection, Click, and Rich Formatting

If you’ve ever wanted to make your CLI more interactive and dynamic, building a real-time command interaction system could be the answer. By leveraging Python’s introspection capabilities, Click for managing commands, and Rich for formatting output, you can create a powerful, flexible CLI that responds intelligently to user input. Instead of manually hardcoding each command, your CLI can automatically discover and execute commands, making the user experience smoother and more engaging.

Colorful console chaos: where Click commands meet Rich output—because even the terminal likes to show off in style!

Why Use Click and Markdown?

Click simplifies the management of commands, argument parsing, and help generation. It also allows for easy command structuring and option handling.

Rich enables you to output beautifully formatted Markdown directly in the terminal, making results not just functional but also visually engaging.

By combining these two libraries with Python introspection, you can build an interactive chat feature that dynamically discovers and executes commands while displaying output in a rich, readable format. For a practical example, see how StoryCraftr uses a similar approach to streamline AI-driven writing workflows: https://storycraftr.app.

Building the Interactive Chat System

1. Setting Up the Basic Chat Command

The chat command initializes the session, allowing users to interact with the CLI. Here, we capture user input, which will be dynamically mapped to the appropriate Click commands.

import os
import click
import shlex
from rich.console import Console
from rich.markdown import Markdown

console = Console()

@click.command()
@click.option("--project-path", type=click.Path(), help="Path to the project directory")
def chat(project_path=None):
    """
    Start a chat session with the assistant for the given project.
    """
    if not project_path:
        project_path = os.getcwd()

    console.print(
        f"Starting chat for [bold]{project_path}[/bold]. Type [bold green]exit()[/bold green] to quit."
    )

    # Start the interactive session
    while True:
        user_input = console.input("[bold blue]You:[/bold blue] ")

        # Handle exit
        if user_input.lower() == "exit()":
            console.print("[bold red]Exiting chat...[/bold red]")
            break

        # Call the function to handle command execution
        execute_cli_command(user_input)
Enter fullscreen mode Exit fullscreen mode

2. Introspection to Discover and Execute Commands

Using Python introspection, we dynamically discover available commands and execute them. One crucial part here is that Click commands are decorated functions. To execute the actual logic, we need to call the undecorated function (i.e., the callback).

Here’s how you can dynamically execute commands using introspection and handle Click’s decorators:

import inspect
import your_project_cmd  # Replace with your actual module containing commands

command_modules = {"project": your_project_cmd}  # List your command modules here

def execute_cli_command(user_input):
    """
    Function to execute CLI commands dynamically based on the available modules,
    calling the undecorated function directly.
    """
    try:
        # Use shlex.split to handle quotes and separate arguments correctly
        parts = shlex.split(user_input)
        module_name = parts[0]
        command_name = parts[1].replace("-", "_")  # Replace hyphens with underscores
        command_args = parts[2:]  # Keep the rest of the arguments as a list

        # Check if the module exists in command_modules
        if module_name in command_modules:
            module = command_modules[module_name]

            # Introspection: Get the function by name
            if hasattr(module, command_name):
                cmd_func = getattr(module, command_name)

                # Check if it's a Click command and strip the decorator
                if hasattr(cmd_func, "callback"):
                    # Call the underlying undecorated function
                    cmd_func = cmd_func.callback

                # Check if it's a callable (function)
                if callable(cmd_func):
                    console.print(
                        f"Executing command from module: [bold]{module_name}[/bold]"
                    )

                    # Directly call the function with the argument list
                    cmd_func(*command_args)
                else:
                    console.print(
                        f"[bold red]'{command_name}' is not a valid command[/bold red]"
                    )
            else:
                console.print(
                    f"[bold red]Command '{command_name}' not found in {module_name}[/bold red]"
                )
        else:
            console.print(f"[bold red]Module {module_name} not found[/bold red]")
    except Exception as e:
        console.print(f"[bold red]Error executing command: {str(e)}[/bold red]")
Enter fullscreen mode Exit fullscreen mode

How Does This Work?

  • Input Parsing: We use shlex.split to handle input like command-line arguments. This ensures that quoted strings and special characters are processed correctly.
  • Module and Command Lookup: The input is split into module_name and command_name. The command name is processed to replace hyphens with underscores to match the Python function names.
  • Introspection: We use getattr() to dynamically fetch the command function from the module. If it is a Click command (i.e., has the callback attribute), we access the actual function logic by stripping the Click decorator.
  • Command Execution: Once we retrieve the undecorated function, we pass the arguments and call it, just as if we were directly invoking a Python function.

3. Example CLI Commands

Let’s consider some sample commands within a project module that users can call interactively via the chat:

@click.group()
def project():
    """Project management CLI."""
    pass

@project.command()
def init():
    """Initialize a new project."""
    console.print("[bold green]Project initialized![/bold green]")

@project.command()
@click.argument("name")
def create(name):
    """Create a new component in the project."""
    console.print(f"[bold cyan]Component {name} created.[/bold cyan]")

@project.command()
def status():
    """Check the project status."""
    console.print("[bold yellow]All systems operational.[/bold yellow]")
Enter fullscreen mode Exit fullscreen mode

Executing the Chat Interface

To run the interactive chat system:

  1. Make sure your modules (like project) are listed in command_modules.
  2. Run the command:
python your_cli.py chat --project-path /path/to/project
Enter fullscreen mode Exit fullscreen mode

Once the session starts, users can input commands like:

You: project init You: project create "Homepage"
Enter fullscreen mode Exit fullscreen mode

The output will be displayed in a well-formatted manner using Rich Markdown:

[bold green]Project initialized![/bold green] [bold cyan]Component Homepage created.[/bold cyan]
Enter fullscreen mode Exit fullscreen mode

Conclusion

By combining Click for command management, Rich for Markdown formatting, and Python introspection, we can build a powerful and interactive chat system for CLIs. This approach allows you to dynamically discover and execute commands while presenting output in an elegant, readable format.

Key Highlights:

  • Dynamic Command Execution: Introspection enables you to discover and run commands without hardcoding them.
  • Rich Output: Using Rich Markdown ensures the output is easy to read and visually appealing.
  • Flexibility: This setup allows for flexibility in command structure and execution.

Top comments (0)