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 ferramenta | TUI interativa? | Usa IA/LLM? | Caminho correto |
|---|---|---|---|
| CLI pura (aise, git, curl) | Não | Não | CLI: o harness É a gravação |
| CLI + sessão de IA (claude -p) | Sem TUI | Sim | CLI: verifique se a saída é útil |
| Plugin/hook para ferramenta TUI | Via TUI | Sim | TUI ao vivo: tmux + painel |
| Plugin com ações apenas via hook | Via hook | Não | TUI com script: run_hook() + --play |
| Inicia TUI interativa | Sim | Talvez | TUI ao vivo: conduza a TUI via tmux |
| Ferramenta de lote/configuração | Não | Não | CLI: 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)
- Leia a documentação da ferramenta primeiro — antes de escrever uma única ação
- Escolha 5–7 recursos que sejam visíveis, imediatos e autoexplicativos para novos usuários
- Pule recursos invisíveis (daemons em segundo plano, salvamento automático sem saída visível)
- 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 gravapython test_demo.py --run-acts. - TUI com script: Script Python que chama
run_hook()diretamente para cada ação. O asciinema gravapython 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:
- 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.
- 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]