Communitygithub.com

ahundt/cli-demo-recorder

Crie vídeos de demonstração para ferramentas CLI e TUI. Use quando o usuário pedir para 'gravar uma demo', 'fazer um GIF de demonstração', 'criar um vídeo de demonstração', 'gravar minha ferramenta CLI', 'demo asciinema', 'script de gravação de demonstração'. Abrange o pipeline asciinema+agg+ffmpeg, gravação com tmux, múltiplos shells e harnesses de demonstração + teste duplo em Python, Rust, TypeScript e Bash.

O que é cli-demo-recorder?

Este skill (Habilidade) permite criar vídeos de demonstração profissionais para ferramentas de linha de comando (CLI) e interfaces de terminal (TUI). Ele suporta dois caminhos principais: o caminho CLI, onde um script Python atua como a própria gravação, chamando subprocessos sem capturar saída para que o asciinema possa gravar tudo; e o caminho TUI, que usa tmux para sessões interativas com IA, onde o asciinema se anexa a uma sessão tmux. O skill fornece um fluxo de trabalho completo: planejamento (selecionar 5-7 recursos visíveis), construção do harness de demonstração (em Python, com funções como `_type()`, `_run()`, `section()`), gravação com asciinema, e conversão para GIF/MP4 usando agg e ffmpeg. Inclui dicas cruciais, como evitar `capture_output=True` (que silencia a gravação) e a necessidade de isolar dados de demonstração para não vazar informações do operador. Compatível com várias plataformas de agentes de IA (Claude Code, Cursor, Codex) e linguagens (Python, Rust, TypeScript, Bash).

Funciona com✓Claude Code~Codex CLI✓Cursor✓Gemini CLI
npx skills add https://github.com/ahundt/autorun/tree/HEAD/plugins/autorun/skills/cli-demo-recorder

Perguntar na sua IA favorita

Abre um novo chat com esta habilidade de agente já pré-carregada.

Documentação

CLI Demo Recorder

Grave vídeos de demonstração polidos para ferramentas CLI — seja como gravações diretas de subprocessos (ferramentas CLI, sem sessão de IA) ou gravações TUI reais (sessões interativas de IA via tmux).

Use este skill quando: for necessário um GIF/vídeo de demonstração para uma ferramenta CLI, plugin ou aplicação de terminal. Invoque com: /ar:cli-demo-recorder (plugin de execução automática) ou /cli-demo-recorder (raiz de skills), ou "Ajude-me a gravar uma demonstração para minha ferramenta CLI"


Escolha seu Caminho [Ambos]

Escolha o caminho com base em se a ferramenta tem uma sessão TUI interativa. Escolha errado → a gravação não captura nada útil.

Tipo de ferramentaTUI interativa?Usa IA/LLM?Caminho correto
CLI pura (aise, git, curl)NãoNãoCLI: o harness É a gravação
CLI + sessão de IA (claude -p)Sem TUISimCLI: verifique se a saída é útil
Plugin/hook para ferramenta TUIVia TUISimTUI ao vivo: tmux + painel
Plugin com ações apenas via hookVia hookNãoTUI com script: run_hook() + --play
Inicia TUI interativaSimTalvezTUI ao vivo: conduza a TUI via tmux
Ferramenta de lote/configuraçãoNãoNãoCLI: capture_output=False

AVISO: Usar subprocess.run(capture_output=True) para uma demonstração CLI silencia a gravação completamente — o asciinema não captura nada. Veja o caminho CLI para o padrão correto.


Como Funciona [Ambos]

Fase 1: Planejamento (15–30 min)

  1. Leia a documentação da ferramenta primeiro — antes de escrever uma única ação
  2. Escolha 5–7 recursos que sejam visíveis, imediatos e autoexplicativos para novos usuários
  3. Pule recursos invisíveis (daemons em segundo plano, salvamento automático sem saída visível)
  4. Escolha seu caminho (veja a tabela acima) — isso determina todo o design do harness

Fase 2: Construa o Harness (30–60 min)

  • CLI: Script Python que chama subprocess.run(..., capture_output=False). O asciinema grava python test_demo.py --run-acts.
  • TUI com script: Script Python que chama run_hook() diretamente para cada ação. O asciinema grava python test_demo.py --play.
  • TUI ao vivo: Script Python que cria uma sessão tmux, envia prompts via send-keys, e o asciinema se anexa a essa sessão.

Fase 3: Grave e Verifique (10–30 min)

  • CLI: python tests/test_demo.py --record → verifica fragmentos de texto do cast
  • TUI: python tests/test_demo.py --record → analisa JSONL em busca de chamadas de ferramenta

Fase 4: Converta [Ambos]

# valores da tabela de configurações abaixo; fontdue renderiza texto anti-aliasing de qualidade vetorial
agg demo.cast demo.gif \
    --theme dracula \
    --font-size 16 \
    --renderer fontdue \
    --speed 0.75 \
    --idle-time-limit 10

# MP4: fallback de 4 estratégias (melhor compressão primeiro)
# Estratégia 1: libx265 HEVC (tune=animation — ~50% menor que libx264 na mesma qualidade)
ffmpeg -y -i demo.gif -movflags faststart \
    -vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" \
    -c:v libx265 -preset slow -crf 28 -tune animation \
    -pix_fmt yuv420p -tag:v hvc1 demo.mp4 2>/dev/null \
  || ffmpeg -y -i demo.gif -movflags faststart \
    -vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" \
    -c:v libx264 -preset slow -crf 28 -tune animation \
    -pix_fmt yuv420p demo.mp4 2>/dev/null \
  || ffmpeg -y -i demo.gif -movflags faststart \
    -vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" \
    -c:v h264_videotoolbox -q:v 65 -pix_fmt yuv420p -color_range tv demo.mp4 2>/dev/null \
  || ffmpeg -y -i demo.gif -vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" \
    -pix_fmt yuv420p demo.mp4

Importante: Converta GIF → MP4 (não cast → MP4). O GIF já está processado; ir de cast → MP4 diretamente perde os ajustes de velocidade/ociosidade do agg. Use tune=animation (não tune=fast) — gravações de terminal têm cores planas e bordas nítidas que combinam com compressão de animação.

Total: ~75–120 minutos para uma demonstração polida e verificada.


Caminho CLI [Apenas CLI]

Arquitetura: Harness É a Gravação

Para ferramentas CLI, o harness Python é executado como o comando que o asciinema grava. Sem tmux, sem anexação de painel.

asciinema rec demo.cast --command "python test_demo.py --run-acts"
                                                        ↑ harness executa aqui
  → harness digita $ prompt, executa subprocess com capture_output=False
  → asciinema captura todo stdout do processo
  → agg demo.cast demo.gif
# ✅ CORRETO — a saída flui para o terminal, o asciinema a captura
subprocess.run("mytool subcommand", shell=True, capture_output=False, env=DEMO_ENV)

# ❌ ERRADO para demonstrações CLI — captura a saída no Python, o asciinema não vê nada
result = subprocess.run(["mytool", "subcommand"], capture_output=True)

Auxiliares Principais [Apenas CLI]

_TIMED = False  # True apenas dentro de --run-acts (modo de gravação); _DEMO_WITH_TIMING também é comum

def pause(seconds: float) -> None:
    """No-op em pytest; dorme durante a gravação. Erros 1+2: tempo ≠ espaçamento."""
    if _TIMED:
        time.sleep(seconds)

def _type(text: str, delay: float = 0.04) -> None:
    if _TIMED:
        for ch in text:
            sys.stdout.write(ch); sys.stdout.flush(); time.sleep(delay)
    else:
        sys.stdout.write(text); sys.stdout.flush()

def _run(cmd: str) -> None:
    """Mostra $ prompt digitado e executa o comando.
    capture_output=False é CRÍTICO — a saída deve fluir para o terminal.
    """
    _type(f"\n\033[1;32m$\033[0m ", delay=0)
    _type(cmd + "\n", delay=0.045)
    pause(0.3)
    subprocess.run(cmd, env=DEMO_ENV, shell=True, capture_output=False, text=True)

def section(title: str) -> None:
    """Divisor visual de seção entre ações.

    3 novas linhas ANTES da barra = lacuna visual da ação anterior (altere isso para espaçamento).
    Durações de pause() = tempo de leitura (altere separadamente para tempo).
    Estas são variáveis INDEPENDENTES — não as confunda.

    bar_len = max(68, len(title) + 6) evita barras mais curtas que o título.
    """
    bar_len = max(68, len(title) + 6)
    bar = "─" * bar_len
    sys.stdout.write(f"\n\n\n\033[90m{bar}\033[0m\n")
    sys.stdout.write(f"\033[1;96m  {title}\033[0m\n")
    sys.stdout.write(f"\033[90m{bar}\033[0m\n")
    sys.stdout.flush()

Banner de Introdução [Apenas CLI]

Para ferramentas CLI, o banner é uma string Python impressa diretamente no stdout:

def banner() -> None:
    W = 68  # calcule o preenchimento APENAS no texto simples — sem códigos ANSI dentro de len()
    def row(text: str = "", style: str = "") -> str:
        content = (" " + text).ljust(W)  # W caracteres visíveis; sem ANSI no comprimento
        return f"\033[90m  ║\033[0m{style}{content}\033[0m\033[90m║\033[0m"
    # ❌ ERRADO — códigos ANSI inflam len(), desalinhando o preenchimento:
    # wrong = f"\033[1m{text}\033[0m".ljust(W)
    lines = [
        f"\033[90m  ╔{'═'*W}╗\033[0m",
        row("mytool — slogan aqui", "\033[1;96m"),
        row(),
        row("Esta demonstração mostra:", "\033[90m"),
        row("  1. Recurso um", "\033[90m"),
        row("  2. Recurso dois", "\033[90m"),
        f"\033[90m  ╚{'═'*W}╝\033[0m",
    ]
    print("\n" + "\n".join(lines) + "\n")

Isolamento de Privacidade [Apenas CLI]

# DEMO_DATA_DIR: fixtures sintéticas versionadas (sem dados reais de usuário)
# TOOL_ISOLATION_VAR: variável de ambiente que redireciona as leituras de dados da ferramenta
# Exemplos: CLAUDE_CONFIG_DIR (aise), XDG_DATA_HOME, APP_DATA_DIR
DEMO_DATA_DIR = Path(__file__).parent / "tool-demo"
DEMO_ENV = {**os.environ, "TOOL_ISOLATION_VAR": str(DEMO_DATA_DIR)}

Redirecionar leituras de dados não é suficiente para comandos que relatam estado de instalação (status, doctor, install, --version -v): eles também imprimem HOME, PATH e raízes relativas a configuração. Realoque todos os três e filtre o cast decodificado para o home, login, hostname e email do operador — detalhes em references/common-pitfalls.md, seção "Comandos de estado de instalação vazam a máquina do operador".

Fixtures com Datas Deslocadas [Apenas CLI]

Necessário quando ações de demonstração usam --since Nd, --after DATE ou qualquer filtro relativo a tempo.

def create_dated_demo_dir() -> Path:
    """Copia DEMO_DATA_DIR para um diretório temporário com carimbos de data/hora deslocados para perto de hoje.
    Sem isso: fixtures de meses atrás → 0 resultados para --since 3d.
    Os arquivos de fixture versionados NUNCA são modificados — apenas a cópia temporária é deslocada.
    Adapte _TS_RE e a lógica de deslocamento para corresponder ao formato de carimbo de data/hora da sua ferramenta.
    """
    _TS_RE = re.compile(r'"timestamp":\s*"(\d{4}-\d{2}-\d{2}T[^"]+)"')
    # encontre o carimbo de data/hora máximo nas fixtures, calcule o delta para (hoje - 1 dia)
    tmp = Path(tempfile.mkdtemp(prefix="tool-demo-dated-"))
    shutil.copytree(DEMO_DATA_DIR, tmp / "data")
    for f in (tmp / "data").rglob("*.jsonl"):
        f.write_text(_TS_RE.sub(lambda m: shift_ts(m, delta), f.read_text()))
    return tmp

Gravação [Apenas CLI]

def record(cast_file: Path) -> None:
    dated_dir = create_dated_demo_dir()
    record_env = {**os.environ, "TOOL_ISOLATION_VAR": str(dated_dir)}
    try:
        subprocess.run([
            asciinema, "rec", str(cast_file),
            "--command", f"{sys.executable} {__file__} --run-acts",
            "--window-size", "160x48",   # 100x35 para ferramentas estreitas
            "--capture-env", "TERM,COLORTERM,TOOL_ISOLATION_VAR",
        ], env=record_env, check=True)
    finally:
        shutil.rmtree(dated_dir, ignore_errors=True)

Limpeza do Quadro Inicial [Ambos]

O asciinema grava a inicialização do shell (prompt, variáveis de ambiente, arquivos RC) antes do harness começar. Isso produz um primeiro quadro confuso com artefatos do shell em vez de um banner limpo. Corrija isso com um corte pós-gravação que encontra o marcador do banner, retrocede até a sequência de limpeza de tela anterior, descarta todos os eventos anteriores e redefine os carimbos de data/hora para t=0.

def trim_cast_to_banner(cast_file: Path, banner_marker: str = "mytool") -> None:
    """Corta eventos de inicialização do shell para que o banner seja o primeiro quadro visível.

    Modifica cast_file localmente. Preserva a linha de cabeçalho JSON.
    """
    lines = cast_file.read_text().splitlines()
    if len(lines) < 2:
        return

    header = lines[0]   # Cabeçalho JSON — sempre linha 1
    events = lines[1:]

    # Encontre o primeiro evento contendo o texto do marcador do banner.
    banner_idx = None
    for i, line in enumerate(events):
        if banner_marker in line:
            banner_idx = i
            break

    if banner_idx is None:
        print(f"[corte] marcador {banner_marker!r} não encontrado — pulando corte")
        return

    # Retroceda até a sequência de limpeza de tela logo antes do banner.
    clear_idx = banner_idx
    for i in range(banner_idx - 1, -1, -1):
        if "\\u001b[H\\u001b[2J" in events[i] or "\\033[H\\033[2J" in events[i]:
            clear_idx = i
            break

    kept = events[clear_idx:]
    if not kept:
        return

    # Redefina carimbos de data/hora: primeiro evento mantido se torna t=0.
    first_ts = json.loads(kept[0])[0]
    rebased = []
    for line in kept:
        evt = json.loads(line)
        evt[0] = round(evt[0] - first_ts, 6)
        rebased.append(json.dumps(evt))

    cast_file.write_text(header + "\n" + "\n".join(rebased) + "\n")
    trimmed = len(events) - len(kept)
    print(f"[corte] Removidos {trimmed} eventos antes do banner (mantidos {len(kept)})")

Chame após a gravação, antes da conversão: trim_cast_to_banner(CAST_FILE, banner_marker="mytool_name"). Para implementações em Rust e TypeScript, veja references/rust-demos.md e references/typescript-demos.md.

Escreva Ações Primeiro, Depois Testes Correspondentes [Apenas CLI]

Causa raiz da deriva teste/demonstração: escrever testes independentemente das ações de demonstração.

✅ Fluxo de trabalho correto:
1. Finalize run_demo_acts() — corrija a string de comando EXATA para cada ação
2. Escreva TestDemoFree usando ESSAS MESMAS strings de comando, textualmente
3. Nunca adicione --format, --limit ou flags extras aos testes que não estão na demonstração

❌ Fluxo de trabalho errado:
  ação de demo:  mytool messages search keyword --context 1
  teste:        mytool messages search keyword --format plain --full-uuid
  → teste passa, demonstração mostra saída diferente, teste não prova nada sobre a demonstração

Regra: se um teste precisar de uma flag diferente para verificar a saída, ou (a) adicione essa flag também à ação de demonstração, ou (b) verifique uma propriedade que a saída real da demonstração satisfaz.

Verificação [Apenas CLI]

Verifique se o texto esperado aparece no arquivo cast:

def verify_recording(cast_file: Path) -> bool:
    content = cast_file.read_text()
    checks = [
        ("Sessions:",      "Ação 1: rótulo de estatísticas presente"),
        ("authentication", "Ação 4: resultado da busca mostra palavra-chave"),
    ]
    return all(fragment in content for fragment, _ in checks)

IDs de Fixture Determinísticos [Apenas CLI]

# IDs seguem um padrão reconhecível — fácil de identificar nas gravações
_S1 = str(uuid.UUID("cafe0001-cafe-cafe-cafe-000000000001"))
# IDs estáveis de chamadas de ferramenta entre execuções:
_id = hashlib.md5(f"{session_id}{timestamp}{path}".encode()).hexdigest()[:8]

Mensagem de Encerramento [Apenas CLI]

# Sempre leia o comando de instalação de pyproject.toml ou README — nunca adivinhe
sys.stdout.write(
    "\033[1;32m  ══════════════════════════════════════\033[0m\n"
    "\033[1;32m  ✓  Demonstração concluída — {nome da ferramenta e slogan}\033[0m\n"
    "\033[1;32m  ══════════════════════════════════════\033[0m\n"
    "\n"
    "  Instalar:  {comando exato de pyproject.toml/README}\n"
    "\n"
)

Caminho TUI [Apenas TUI]

Arquitetura: Por que tmux + TUI Real

A primeira versão funcional de uma demonstração tipicamente mostra saída simulada — o harness chama o hook diretamente e imprime o que o hook diria. Isso está errado por duas razões:

  1. Não é o que os usuários veem. Os usuários veem a TUI interativa completa: blocos de chamadas de ferramenta, a resposta conversacional da IA, a mensagem do bloco embutida nessa resposta.
  2. O hook pode não ter sido disparado. Isso é impossível de saber sem registrar as chamadas reais de ferramenta.

Arquitetura correta (TUI ao vivo):

Harness Python
  → cria sessão tmux
  → asciinema anexa: asciinema rec --command "tmux attach-session -t SESSAO"
  → thread em segundo plano: tmux send-keys "claude --dangerously-skip-permissions" Enter
  → verifica confiança/salvamento
...

[truncado para tradução]

Individual skills in this repo

This repo contains 2 individual skills — each has its own dedicated page.

Habilidades Relacionadas