Na primeira parte da nossa jornada, desenhamos como nossa CLI se comportará, desde o pedido do usuário até a conclusão da tarefa. Agora, vamos desenhar o coração do Xabironelson: a Command Line Interface (CLI).

O que é uma CLI

A galera de Ciência da Computação que me perdoe, mas uma CLI é, de forma simples, um sistema baseado em texto que permite a você interagir com o computador por meio de comandos, em vez de uma interface gráfica.

Codificando nossa CLI

Seria um tanto chato se a nossa CLI apenas recebesse uma entrada do usuário e terminasse. Se você já interagiu com um shell (como Bash ou Zsh), já percebeu que ele funciona em um loop, mais conhecido como Read-Eval-Print-Loop (REPL).

Já adianto, não vamos construir o Warp (você pode fazer uma versão mais simples no Build your own Shell - Codecrafters), vamos utilizar, apenas, o REPL para os casos em que precisamos de um Human-in-the-Loop (HITL).

O que é o HITL?

Ah, irmão. Sem Spoiler, preciso te segurar em pelo menos quatro desses textos antes de vender um curso sobre isso te explicar isso.

Criando a configuração da nossa CLI

Uma das premissas que vamos adotar é nos livrarmos do famoso Vendor Lock-In ]. Para isso, temos de tornar a escolha do nosso modelo e dos parâmetros algo fácil. Para isso, adicionaremos uma nova dependência

uv add pyyaml

Criando nosso primeiro comando - Init

Poderíamos utilizar Argparse, Hug, Plac, Click ou qualquer outro nome que pareça uma onomatopeia. Iremos usar o Typer, até porque foi ele que adicionamos na primeira parte do tutorial.

Vamos criar o constants.py. Este arquivo guardará todas as constantes do projeto.

from typing import Dict, Any
 
CONFIG_FILE_NAMEstr = "xabiro.yaml"
 
DEFAULT_CONFIG: Dict[str, Any] = {
    "max_steps": 10,
    "tools": {
        "planner": True,
    },
    "prompt": "Você é um agente de código pessoal chamado Xabironelson Codex. Sua tarefa é ajudar o usuário a escrever, revisar e depurar código. Sempre que possível, forneça exemplos de código e explique conceitos técnicos de maneira clara e concisa.",
    "llm": {
        "model": "gpt-4",
        "temperature": 0.7,
        "max_tokens": 1500,
    }
}

Agora vamos criar o cli.py, o qual será responsável por centralizar toda a configuração da nossa CLI:

import typer
import yaml
import os
from pathlib import Path
from constants import CONFIG_FILE_NAME, DEFAULT_CONFIG
from dotenv import load_dotenv
 
app = typer.Typer(help="Xabironelson Codex")
 
@app.callback()
def main_callback():
    pass
 
@app.command()
def init():
    config_path = Path.cwd() / CONFIG_FILE_NAME
    if config_path.exists():
        typer.confirm(
            f"O arquivo {CONFIG_FILE_NAME} já existe. Deseja sobrescrevê-lo?",
            abort=True
        )
    with open(config_path, "w") as f:
        yaml.dump(DEFAULT_CONFIG, f, sort_keys=False)
 
    typer.secho(f"Arquivo de configuração '{CONFIG_FILE_NAME}' criado com sucesso!", fg=typer.colors.GREEN)

Criando nosso segundo Solve - solve

Agora que temos uma forma de configurar os parâmetros da nossa aplicação, ele se parecerá com algo do tipo:

sequenceDiagram
    participant U as Usuário
    participant CLI as cli.py (solve)
    participant DOTENV as python-dotenv
    participant OS as Ambiente (os.getenv)
    participant CFG as Config. (xabiro.yaml)

    U->>CLI: xabiro-codex solve "Tarefa"

    activate CLI

    CLI->>CLI: typer.secho("Carregando variáveis...")
    CLI->>DOTENV: load_dotenv()
    Note right of DOTENV: Lê o arquivo .env
    DOTENV-->>CLI: Variáveis injetadas no Ambiente

    CLI->>CLI: typer.secho("Carregando configurações...")

    CLI->>CFG: Tenta Abrir(config.yaml)

    alt Carregamento Falha (YAML ou File Not Found)
        CFG-->>CLI: Exceção (YAMLError/FileNotFound)
        CLI->>U: Exibe Erro
        CLI->>CLI: raise typer.Exit(1)
    end

    CFG-->>CLI: Retorna o Conteúdo YAML (config)

    CLI->>CLI: Extrai llm_configuration e verbose
    CLI->>CLI: Obtém 'api_key_env' do config

    CLI->>OS: os.getenv(api_key_env)
    OS-->>CLI: Retorna o Valor (api_key)

    alt API Key Ausente
        CLI->>U: Exibe ERRO
        CLI->>CLI: raise typer.Exit(1)
    end

    CLI->>CLI: Exibe [CONFIGURAÇÕES CARREGADAS]

    CLI->>U: Exibe Tarefa, LLM Params e Status da Chave API

    CLI->>CLI: (TODO) Chama Lógica do Agente

    deactivate CLI

Note que ainda não temos o nosso agente sendo chamado, mas já conseguimos ver onde ele se encaixará. Porém, isso será feito na próxima aula. Transformando esse mermaid em código teríamos:

@app.command()
def solve(
        task: str = typer.Argument(..., help="Descrição da tarefa que o agente deve realizar."),
        config_file: Path = typer.Option(CONFIG_FILE_NAME, "--config", "-c", exists=True, file_okay=True, dir_okay=False, writable=False, help="Caminho para o arquivo de configuração."),
    ):
 
    typer.secho("Carregando variáveis de ambiente...", fg=typer.colors.BLUE)
    load_dotenv()
 
    typer.secho(f"Carregando configurações de: {config_file}", fg=typer.colors.CYAN)
 
    try:
        with open(config_file, "r") as f:
            config = yaml.safe_load(f)
    except yaml.YAMLError as e:
        typer.secho(f"Erro ao carregar o arquivo de configuração: {e}", fg=typer.colors.RED)
        raise typer.Exit(code=1)
    except FileNotFoundError:
        typer.secho(f"Erro: Arquivo de configuração '{config_file}' não encontrado.", fg=typer.colors.RED)
        raise typer.Exit(code=1)
 
    llm_configuration = config.get("llm", {})
    verbose = config.get("verbose", False)
 
    env_var_name = llm_configuration.get("api_key_env", "LLM_API_KEY")
    api_key = os.getenv(env_var_name)
 
    if not api_key:
        typer.secho("\nERRO: Chave API não encontrada!", fg=typer.colors.RED)
        typer.echo(f"A variável de ambiente '{env_var_name}' não foi definida.")
        typer.echo("Certifique-se de criar um arquivo .env ou exportar a variável.")
        raise typer.Exit(code=1)
 
    typer.echo("\n[CONFIGURAÇÕES CARREGADAS]")
    typer.secho(f" Tarefa: {task}", fg=typer.colors.YELLOW)
 
    if llm_configuration:
        typer.echo(f" Modelo: {llm_configuration.get('model', 'N/A')}")
        typer.echo(f" Temperatura: {llm_configuration.get('temperature', 'N/A')}")
        typer.echo(f" Max Tokens: {llm_configuration.get('max_tokens', 'N/A')}")
        typer.secho(f" Chave API carregada de: {env_var_name} (OK)", fg=typer.colors.GREEN)
        typer.echo(f" Modo Verbose: {verbose}")

E agora?

O nosso próximo passo é criarmos a nossa camada de abstração para interagirmos com a LLM. Por agora, vamos imaginar que a LLM é apenas uma API.

Não me mate

Pô, eu entendo o quão complexo é uma LLM, mas, do ponto de vista de uma aplicação consumidora, ela é, apenas, um provedor terceiro de dados. E por estar fora do nosso domínio, temos de pensar em como aplicar todas as boas práticas para termos uma boa interação.

Má pera lá! Será que compensa criar toda essa camada de abstração?

Trade-off make vs. buy

Ao criarmos essa camada de abstração, nos deparamos com a decisão fundamental:

  1. Make: Construir classes e wrappers do zero para garantir 100% de controle sobre o agnosticismo de LLM.
  2. Buy: Adotar uma biblioteca pronta como o LiteLLM, que já resolve a compatibilidade entre APIs (OpenAI, Gemini, Anthropic, etc.), nos fazendo ganhar tempo e focando na lógica do agente.

Ambas as abordagens têm seus méritos, e teremos de tomar essa decisão, além de registrá-la numa ADR (Lembra que o seu eu do futuro pode não saber o porquê você fez o que fez).

Mas isso é papo para outra hora!