CLI Demo Recorder
Graba videos de demostración pulidos para herramientas CLI, ya sea como grabaciones directas de subprocesos (herramientas CLI, sin sesión de IA) o grabaciones TUI reales (sesiones interactivas de IA a través de tmux).
Usa esta habilidad cuando: se necesite un GIF/video de demostración para una herramienta CLI, un plugin o una aplicación de terminal.
Invoca con: /ar:cli-demo-recorder (plugin de autorun) o /cli-demo-recorder (raíz de habilidades), o "Ayúdame a grabar una demo para mi herramienta CLI"
Elige tu Ruta [Ambos]
Elige la ruta según si la herramienta tiene una sesión TUI interactiva. Una elección incorrecta hará que la grabación no capture nada útil.
| Tipo de herramienta | ¿TUI interactiva? | ¿Usa IA/LLM? | Ruta correcta |
|---|---|---|---|
| CLI pura (aise, git, curl) | No | No | CLI: el arnés ES la grabación |
| CLI + sesión de IA (claude -p) | Sin TUI | Sí | CLI: verifica que la salida sea útil |
| Plugin/hook para herramienta TUI | Vía TUI | Sí | TUI en vivo: tmux + panel |
| Plugin con acciones solo mediante hook | Vía hook | No | TUI scripteado: run_hook() + --play |
| Genera TUI interactiva | Sí | Quizás | TUI en vivo: manejar TUI vía tmux |
| Herramienta batch/config | No | No | CLI: capture_output=False |
ADVERTENCIA: Usar subprocess.run(capture_output=True) para una demo de CLI silencia la grabación por completo — asciinema no captura nada. Consulta la ruta CLI para el patrón correcto.
Cómo Funciona [Ambos]
Fase 1: Planificar (15–30 min)
- Lee la documentación de la herramienta primero — antes de escribir una sola acción
- Elige 5–7 características que sean visibles, inmediatas y autoexplicativas para los nuevos usuarios
- Omite características invisibles (demonios en segundo plano, auto-guardado sin salida visible)
- Elige tu ruta (ver tabla anterior) — esto determina todo el diseño del arnés
Fase 2: Construir el Arnés (30–60 min)
- CLI: Script de Python que llama a
subprocess.run(..., capture_output=False). asciinema grabapython test_demo.py --run-acts. - TUI scripteado: Script de Python que llama a
run_hook()directamente para cada acción. asciinema grabapython test_demo.py --play. - TUI en vivo: Script de Python que crea una sesión de tmux, envía indicaciones vía
send-keys, y asciinema se adjunta a esa sesión.
Fase 3: Grabar y Verificar (10–30 min)
- CLI:
python tests/test_demo.py --record→ verifica fragmentos de texto del archivo cast - TUI:
python tests/test_demo.py --record→ analiza JSONL para llamadas a herramientas
Fase 4: Convertir [Ambos]
# valores de la tabla de configuración a continuación; fontdue renderiza texto con suavizado de calidad vectorial
agg demo.cast demo.gif \
--theme dracula \
--font-size 16 \
--renderer fontdue \
--speed 0.75 \
--idle-time-limit 10
# MP4: repliegue de 4 estrategias (mejor compresión primero)
# Estrategia 1: libx265 HEVC (tune=animation — ~50% más pequeño que libx264 con la misma calidad)
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: Convierte GIF → MP4 (no cast → MP4). El GIF ya está procesado; ir de cast → MP4 directamente omite los ajustes de velocidad/inactividad de agg. Usa tune=animation (no tune=fast) — las grabaciones de terminal tienen colores planos y bordes afilados que coinciden con la compresión de animación.
Total: ~75–120 minutos para una demo pulida y verificada.
Ruta CLI [Solo CLI]
Arquitectura: El Arnés ES la Grabación
Para herramientas CLI, el arnés de Python se ejecuta como el comando que asciinema graba. Sin tmux, sin adjuntar paneles.
asciinema rec demo.cast --command "python test_demo.py --run-acts"
↑ el arnés se ejecuta aquí
→ el arnés escribe el $ prompt, ejecuta subprocess con capture_output=False
→ asciinema captura todo el stdout del proceso
→ agg demo.cast demo.gif
# ✅ CORRECTO — la salida fluye al terminal, asciinema la captura
subprocess.run("mytool subcommand", shell=True, capture_output=False, env=DEMO_ENV)
# ❌ INCORRECTO para demos CLI — captura la salida en Python, asciinema no ve nada
result = subprocess.run(["mytool", "subcommand"], capture_output=True)
Ayudantes Principales [Solo CLI]
_TIMED = False # True solo dentro de --run-acts (modo grabación); _DEMO_WITH_TIMING también común
def pause(seconds: float) -> None:
"""No-op en pytest; duerme durante la grabación. Errores 1+2: tiempo ≠ espaciado."""
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:
"""Muestra el $ prompt escrito, luego ejecuta el comando.
capture_output=False es CRÍTICO — la salida debe fluir al 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:
"""Separador visual de secciones entre acciones.
3 saltos de línea ANTES de la barra = espacio visual de la acción anterior (cambia esto para el espaciado).
Las duraciones de pause() = tiempo de lectura (cámbialas por separado para el tiempo).
Estas son perillas INDEPENDIENTES — no las mezcles.
bar_len = max(68, len(title) + 6) evita barras más cortas que el 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 Introducción [Solo CLI]
Para herramientas CLI, el banner es una cadena de Python impresa directamente en stdout:
def banner() -> None:
W = 68 # calcula el padding solo en texto PLANO — sin códigos ANSI dentro de math len()
def row(text: str = "", style: str = "") -> str:
content = (" " + text).ljust(W) # W caracteres visibles; sin ANSI en la longitud
return f"\033[90m ║\033[0m{style}{content}\033[0m\033[90m║\033[0m"
# ❌ INCORRECTO — los códigos ANSI inflan len(), desalinean el padding:
# bad = f"\033[1m{text}\033[0m".ljust(W)
lines = [
f"\033[90m ╔{'═'*W}╗\033[0m",
row("mytool — eslogan aquí", "\033[1;96m"),
row(),
row("Esta demo muestra:", "\033[90m"),
row(" 1. Característica uno", "\033[90m"),
row(" 2. Característica dos", "\033[90m"),
f"\033[90m ╚{'═'*W}╝\033[0m",
]
print("\n" + "\n".join(lines) + "\n")
Aislamiento de Privacidad [Solo CLI]
# DEMO_DATA_DIR: fixtures sintéticos confirmados (sin datos reales de usuario)
# TOOL_ISOLATION_VAR: variable de entorno que redirige las lecturas de datos de la herramienta
# Ejemplos: 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)}
Redirigir las lecturas de datos no es suficiente para comandos que informan el estado de instalación
(status, doctor, install, --version -v): también imprimen HOME, PATH, y raíces
relativas a la configuración. Reubica los tres y filtra el cast decodificado para el
home, login, hostname y correo del operador — detalles en references/common-pitfalls.md,
sección "Los comandos de estado de instalación filtran la máquina del operador".
Fixtures con Desplazamiento de Fecha [Solo CLI]
Necesario cuando las acciones de la demo usan --since Nd, --after DATE o cualquier filtro relativo al tiempo.
def create_dated_demo_dir() -> Path:
"""Copia DEMO_DATA_DIR a un directorio temporal con marcas de tiempo desplazadas a cerca de hoy.
Sin esto: fixtures de hace meses → 0 resultados para --since 3d.
Los archivos fixture confirmados NUNCA se modifican — solo se desplaza la copia temporal.
Adapta _TS_RE y la lógica de desplazamiento al formato de marca de tiempo de tu herramienta.
"""
_TS_RE = re.compile(r'"timestamp":\s*"(\d{4}-\d{2}-\d{2}T[^"]+)"')
# encuentra la marca de tiempo máxima en los fixtures, calcula delta a (hoy - 1 día)
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
Grabación [Solo 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 herramientas estrechas
"--capture-env", "TERM,COLORTERM,TOOL_ISOLATION_VAR",
], env=record_env, check=True)
finally:
shutil.rmtree(dated_dir, ignore_errors=True)
Limpieza del Fotograma Inicial [Ambos]
Asciinema graba la inicialización del shell (prompt, variables de entorno, archivos RC) antes de que el arnés comience. Esto produce un primer fotograma desordenado con artefactos del shell en lugar de un banner limpio. Soluciona esto con un recorte posterior a la grabación que encuentra el marcador del banner, retrocede hasta el escape de limpieza de pantalla anterior, elimina todos los eventos previos y reajusta las marcas de tiempo a t=0.
def trim_cast_to_banner(cast_file: Path, banner_marker: str = "mytool") -> None:
"""Recorta los eventos de inicialización del shell para que el banner sea el primer fotograma visible.
Modifica cast_file in situ. Conserva la línea de encabezado JSON.
"""
lines = cast_file.read_text().splitlines()
if len(lines) < 2:
return
header = lines[0] # encabezado JSON — siempre línea 1
events = lines[1:]
# Encuentra el primer evento que contenga el texto del marcador del 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"[trim] marcador {banner_marker!r} no encontrado — omitiendo recorte")
return
# Retrocede al escape de limpieza de pantalla justo antes del 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
# Reajusta marcas de tiempo: el primer evento conservado se convierte en 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"[trim] Se eliminaron {trimmed} eventos antes del banner (se conservaron {len(kept)})")
Llama después de la grabación, antes de la conversión: trim_cast_to_banner(CAST_FILE, banner_marker="mytool_name").
Para implementaciones en Rust y TypeScript, consulta references/rust-demos.md y references/typescript-demos.md.
Escribe las Acciones Primero, Luego las Pruebas Correspondientes [Solo CLI]
Causa raíz de la deriva prueba/demo: escribir pruebas independientemente de las acciones de la demo.
✅ Flujo de trabajo correcto:
1. Finaliza run_demo_acts() — fija la cadena de comando EXACTA para cada acción
2. Escribe TestDemoFree usando ESAS MISMAS cadenas de comando, textualmente
3. Nunca agregues --format, --limit o banderas adicionales a las pruebas que no estén en la demo
❌ Flujo de trabajo incorrecto:
acción demo: mytool messages search keyword --context 1
prueba: mytool messages search keyword --format plain --full-uuid
→ la prueba pasa, la demo muestra salida diferente, la prueba no prueba nada sobre la demo
Regla: si una prueba necesita una bandera diferente para verificar la salida, ya sea (a) agrega esa bandera también a la acción de la demo, o (b) verifica una propiedad que la salida real de la demo cumpla.
Verificación [Solo CLI]
Verifica que el texto esperado aparezca en el archivo cast:
def verify_recording(cast_file: Path) -> bool:
content = cast_file.read_text()
checks = [
("Sessions:", "Acto 1: etiqueta de estadísticas presente"),
("authentication", "Acto 4: resultado de búsqueda muestra la palabra clave"),
]
return all(fragment in content for fragment, _ in checks)
IDs Deterministas de Fixtures [Solo CLI]
# Los IDs siguen un patrón reconocible — fáciles de detectar en las grabaciones
_S1 = str(uuid.UUID("cafe0001-cafe-cafe-cafe-000000000001"))
# IDs estables de llamadas a herramientas entre ejecuciones:
_id = hashlib.md5(f"{session_id}{timestamp}{path}".encode()).hexdigest()[:8]
Mensaje de Cierre [Solo CLI]
# Siempre lee el comando de instalación de pyproject.toml o README — nunca adivines
sys.stdout.write(
"\033[1;32m ══════════════════════════════════════\033[0m\n"
"\033[1;32m ✓ Demo completa — {nombre de la herramienta y eslogan}\033[0m\n"
"\033[1;32m ══════════════════════════════════════\033[0m\n"
"\n"
" Instalar: {comando exacto de pyproject.toml/README}\n"
"\n"
)
Ruta TUI [Solo TUI]
Arquitectura: Por Qué tmux + TUI Real
La primera versión funcional de una demo típicamente muestra una salida simulada — el arnés llama al hook directamente e imprime lo que el hook diría. Esto es incorrecto por dos razones:
- No es lo que los usuarios ven. Los usuarios ven la TUI interactiva completa: bloques de llamadas a herramientas, la respuesta conversacional de la IA, el mensaje del bloque incrustado en esa respuesta.
- El hook puede no haberse disparado en absoluto. Esto es imposible de saber sin grabar las llamadas a herramientas reales.
Arquitectura correcta (TUI en vivo):
Arnés de Python
→ crea una sesión de tmux
→ asciinema se adjunta: asciinema rec --command "tmux attach-session -t SESSION"
→ hilo en segundo plano: tmux send-keys "claude --dangerously-skip-permissions" Enter
→ consulta trust/saf
...
[truncado para traducción]