Communitygithub.com

ahundt/cli-demo-recorder

Erstelle Demovideos für CLI- und TUI-Tools. Verwende dies, wenn der Benutzer bittet, "eine Demo aufzuzeichnen", "ein Demo-GIF zu erstellen", "ein Demovideo zu machen", "mein CLI-Tool aufzuzeichnen", "asciinema-Demo", "Demo-Aufzeichnungsskript". Deckt die asciinema+agg+ffmpeg-Pipeline, tmux-Aufzeichnung, Multi-Shell und doppelte Demo+Test-Harnesses in Python, Rust, TypeScript und Bash ab.

Was ist cli-demo-recorder?

Dieses Skill bietet eine umfassende Anleitung und eine Reihe von Hilfsmitteln zum Aufzeichnen professioneller Demovideos für CLI- und TUI-Tools. Es automatisiert den Prozess der Erstellung von GIFs und MP4s mithilfe von asciinema, agg und ffmpeg und unterstützt sowohl reine CLI-Tools (ohne interaktive Oberfläche) als auch interaktive TUI-Tools (z. B. solche, die eine KI-Sitzung erfordern). Der Kern des Skills ist ein Python-Harness, der die Demo-Aktionen orchestriert, die Aufzeichnung steuert und die Ausgabe überprüft. Es behandelt häufige Fallstricke wie falsche capture_output-Einstellungen, undichte Installationsstatus-Befehle und zeitbasierte Filter in Testdaten und bietet klare Code-Vorlagen für Banners, Pausen, Datumsverschiebung und Bereinigung des ersten Frames. Das Skill unterstützt die Plattformen Claude-Code, Cursor und Codex und ist in Python, Rust, TypeScript und Bash implementiert. Es wird über den Befehl `/ar:cli-demo-recorder` aktiviert und führt den Benutzer durch die Phasen Planung, Bau der Test-Harness, Aufzeichnung, Überprüfung und Konvertierung, mit einer detaillierten Aufschlüsselung der Zeitschätzung von insgesamt etwa 75–120 Minuten für eine polierte Demo.

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

In Ihrer bevorzugten KI fragen

Öffnet einen neuen Chat, in dem dieser Agent-Skill bereits geladen ist.

Dokumentation

Was macht cli-demo-recorder?

Nimm polierte Demovideos für CLI-Tools auf – entweder als direkte Subprozess-Aufzeichnungen (CLI-Tools, keine KI-Sitzung) oder als echte TUI-Aufzeichnungen (interaktive KI-Sitzungen via tmux).

Verwende dieses Skill, wenn: Ein Demo-GIF/Video für ein CLI-Tool, Plugin oder eine Terminalanwendung benötigt wird. Aufruf mit: /ar:cli-demo-recorder (Autorun-Plugin) oder /cli-demo-recorder (Skills-Root) oder „Hilf mir, eine Demo für mein CLI-Tool aufzuzeichnen“


Wähle deinen Pfad [Beide]

Wähle den Pfad basierend darauf, ob das Tool eine interaktive TUI-Sitzung hat. Falsche Wahl → Aufzeichnung erfasst nichts Nützliches.

Tool-TypInteraktive TUI?Verwendet KI/LLM?Richtiger Pfad
Reines CLI (aise, git, curl)NeinNeinCLI: Harness IST die Aufzeichnung
CLI + KI-Sitzung (claude -p)Keine TUIJaCLI: Überprüfen, ob Ausgabe nützlich ist
Plugin/Hook für TUI-ToolVia TUIJaTUI live: tmux + Pane
Plugin mit Hook-only-AktionenVia HookNeinTUI skriptiert: run_hook() + --play
Startet interaktive TUIJaVielleichtTUI live: TUI via tmux steuern
Batch-/KonfigurationstoolNeinNeinCLI: capture_output=False

WARNUNG: Die Verwendung von subprocess.run(capture_output=True) für ein CLI-Demo unterdrückt die Aufzeichnung vollständig – asciinema erfasst nichts. Siehe CLI-Pfad für das richtige Muster.


So funktioniert es [Beide]

Phase 1: Planen (15–30 Min.)

  1. Lies zuerst die Doku des Tools – bevor du einen einzigen Act schreibst
  2. Wähle 5–7 Funktionen aus, die sichtbar, unmittelbar und für Neulinge selbsterklärend sind
  3. Überspringe unsichtbare Funktionen (Hintergrund-Daemons, automatisches Speichern ohne sichtbare Ausgabe)
  4. Wähle deinen Pfad (siehe Tabelle oben) – dies bestimmt das gesamte Harness-Design

Phase 2: Baue den Harness (30–60 Min.)

  • CLI: Python-Skript, das subprocess.run(..., capture_output=False) aufruft. asciinema zeichnet python test_demo.py --run-acts auf.
  • TUI skriptiert: Python-Skript, das run_hook() direkt für jeden Act aufruft. asciinema zeichnet python test_demo.py --play auf.
  • TUI live: Python-Skript, das eine tmux-Sitzung erstellt, Eingabeaufforderungen via send-keys sendet und asciinema an diese Sitzung anhängt.

Phase 3: Aufzeichnen und Überprüfen (10–30 Min.)

  • CLI: python tests/test_demo.py --record → überprüft Textfragmente im Cast
  • TUI: python tests/test_demo.py --record → parst JSONL auf Tool-Aufrufe

Phase 4: Konvertieren [Beide]

# Werte aus der Einstellungstabelle unten; fontdue rendert Vektorqualität-anti-aliased Text
agg demo.cast demo.gif \
    --theme dracula \
    --font-size 16 \
    --renderer fontdue \
    --speed 0.75 \
    --idle-time-limit 10

# MP4: 4-Strategie-Fallback (beste Komprimierung zuerst)
# Strategie 1: libx265 HEVC (tune=animation — ~50% kleiner als libx264 bei gleicher Qualität)
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

Wichtig: Konvertiere GIF → MP4 (nicht Cast → MP4). Das GIF ist bereits verarbeitet; der direkte Weg Cast → MP4 überspringt die Geschwindigkeits-/Leerlauf-Anpassungen von agg. Verwende tune=animation (nicht tune=fast) – Terminalaufzeichnungen haben flache Farben und scharfe Kanten, die zu Animationskomprimierung passen.

Insgesamt: ~75–120 Minuten für eine polierte, verifizierte Demo.


CLI-Pfad [Nur CLI]

Architektur: Harness IST die Aufzeichnung

Bei CLI-Tools dient der Python-Harness als der Befehl, den asciinema aufzeichnet. Kein tmux, keine Pane-Anbindung.

asciinema rec demo.cast --command "python test_demo.py --run-acts"
                                                        ↑ Harness läuft hier
  → Harness gibt $ prompt aus, führt subprocess mit capture_output=False aus
  → asciinema erfasst alle stdout des Prozesses
  → agg demo.cast demo.gif
# ✅ RICHTIG — Ausgabe fließt zum Terminal, asciinema erfasst sie
subprocess.run("mytool subcommand", shell=True, capture_output=False, env=DEMO_ENV)

# ❌ FALSCH für CLI-Demos — erfasst Ausgabe in Python, asciinema sieht nichts
result = subprocess.run(["mytool", "subcommand"], capture_output=True)

Kern-Helfer [Nur CLI]

_TIMED = False  # Nur True innerhalb von --run-acts (Aufzeichnungsmodus); _DEMO_WITH_TIMING ebenfalls üblich

def pause(seconds: float) -> None:
    """No-op in pytest; schläft während der Aufzeichnung. Fehler 1+2: Timing ≠ Abstand."""
    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:
    """Zeige getipptes $ prompt, dann führe Befehl aus.
    capture_output=False ist KRITISCH — Ausgabe muss zum Terminal fließen.
    """
    _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:
    """Visueller Abschnittstrenner zwischen Acts.

    3 Zeilenumbrüche VOR Balken = visueller Abstand zum vorherigen Act (ändere dies für Abstand).
    pause()-Dauern = Lesezeit (ändere dies separat für Timing).
    Diese sind UNABHÄNGIGE Stellschrauben — vermische sie nicht.

    bar_len = max(68, len(title) + 6) verhindert Balken kürzer als Titel.
    """
    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()

Einführungsbanner [Nur CLI]

Bei CLI-Tools ist das Banner ein Python-String, der direkt nach stdout ausgegeben wird:

def banner() -> None:
    W = 68  # Berechne Padding NUR auf KLARTEXT — keine ANSI-Codes in len()-Mathematik
    def row(text: str = "", style: str = "") -> str:
        content = (" " + text).ljust(W)  # W sichtbare Zeichen; kein ANSI in der Länge
        return f"\033[90m  ║\033[0m{style}{content}\033[0m\033[90m║\033[0m"
    # ❌ FALSCH — ANSI-Codes blähen len() auf, Padding wird falsch:
    # bad = f"\033[1m{text}\033[0m".ljust(W)
    lines = [
        f"\033[90m  ╔{'═'*W}╗\033[0m",
        row("mytool — tagline here", "\033[1;96m"),
        row(),
        row("This demo shows:", "\033[90m"),
        row("  1. Feature one", "\033[90m"),
        row("  2. Feature two", "\033[90m"),
        f"\033[90m  ╚{'═'*W}╝\033[0m",
    ]
    print("\n" + "\n".join(lines) + "\n")

Privatsphären-Isolation [Nur CLI]

# DEMO_DATA_DIR: eingecheckte synthetische Fixtures (keine echten Benutzerdaten)
# TOOL_ISOLATION_VAR: Umgebungsvariable, die die Daten-Lesevorgänge des Tools umleitet
# Beispiele: 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)}

Das Umleiten von Daten-Lesevorgängen reicht nicht für Befehle, die den Installationsstatus melden (status, doctor, install, --version -v): Sie geben auch HOME, PATH und config-relative Wurzeln aus. Verschiebe alle drei und maskiere den decodierten Cast für das Home-Verzeichnis, den Login, den Hostnamen und die E-Mail des Operators – Details in references/common-pitfalls.md, Abschnitt "Installationsstatus-Befehle geben den Rechner des Operators preis".

Datumsverschiebende Fixtures [Nur CLI]

Erforderlich, wenn Demo-Acts --since Nd, --after DATE oder einen anderen zeitrelativen Filter verwenden.

def create_dated_demo_dir() -> Path:
    """Kopiere DEMO_DATA_DIR in ein temporäres Verzeichnis mit Zeitstempeln, die auf nahe heute verschoben wurden.
    Ohne dies: Fixtures von vor Monaten → 0 Ergebnisse für --since 3d.
    Die eingecheckten Fixture-Dateien werden NIEMALS geändert – nur die temporäre Kopie wird verschoben.
    Passe _TS_RE und die Verschiebelogik an das Zeitstempelformat deines Tools an.
    """
    _TS_RE = re.compile(r'"timestamp":\s*"(\d{4}-\d{2}-\d{2}T[^"]+)"')
    # finde maximalen Zeitstempel in Fixtures, berechne Delta zu (heute - 1 Tag)
    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

Aufzeichnung [Nur 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 für schmale Tools
            "--capture-env", "TERM,COLORTERM,TOOL_ISOLATION_VAR",
        ], env=record_env, check=True)
    finally:
        shutil.rmtree(dated_dir, ignore_errors=True)

Bereinigung des ersten Frames [Beide]

Asciinema zeichnet die Shell-Initialisierung (Prompt, Umgebungsvariablen, RC-Dateien) auf, bevor der Harness startet. Dies erzeugt einen unordentlichen ersten Frame mit Shell-Artefakten anstelle eines sauberen Banners. Behebe dies mit einem Trimmen nach der Aufzeichnung, das den Banner-Marker findet, zurück zum vorhergehenden Bildschirmlösch-Escape geht, alle vorherigen Ereignisse verwirft und die Zeitstempel auf t=0 zurücksetzt.

def trim_cast_to_banner(cast_file: Path, banner_marker: str = "mytool") -> None:
    """Trimme Shell-Init-Ereignisse, sodass das Banner der erste sichtbare Frame ist.

    Ändert cast_file direkt. Erhält die JSON-Header-Zeile.
    """
    lines = cast_file.read_text().splitlines()
    if len(lines) < 2:
        return

    header = lines[0]   # JSON-Header — immer Zeile 1
    events = lines[1:]

    # Finde das erste Ereignis, das den Banner-Marker-Text enthält.
    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] marker {banner_marker!r} not found — skipping trim")
        return

    # Gehe zurück zum Bildschirmlösch-Escape direkt vor dem 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

    # Setze Zeitstempel zurück: das erste behaltene Ereignis wird 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] Removed {trimmed} events before banner (kept {len(kept)})")

Rufe nach der Aufzeichnung vor der Konvertierung auf: trim_cast_to_banner(CAST_FILE, banner_marker="mytool_name"). Für Rust- und TypeScript-Implementierungen siehe references/rust-demos.md und references/typescript-demos.md.

Schreibe zuerst Acts, dann passende Tests [Nur CLI]

Hauptursache für Test/Demo-Abweichung: Tests werden unabhängig von den Demo-Acts geschrieben.

✅ Korrekter Workflow:
1. Finalisiere run_demo_acts() — lege die EXAKTE Befehlszeichenkette für jeden Act fest
2. Schreibe TestDemoFree mit DENSELBEN Befehlszeichenketten, wortwörtlich
3. Füge niemals --format, --limit oder zusätzliche Flags zu Tests hinzu, die nicht in der Demo sind

❌ Falscher Workflow:
  Demo-Act:  mytool messages search keyword --context 1
  Test:      mytool messages search keyword --format plain --full-uuid
  → Test bestanden, Demo zeigt andere Ausgabe, Test beweist nichts über die Demo

Regel: Wenn ein Test ein anderes Flag zur Überprüfung der Ausgabe benötigt, entweder (a) füge dieses Flag auch zum Demo-Act hinzu oder (b) überprüfe eine Eigenschaft, die die tatsächliche Ausgabe der Demo erfüllt.

Verifizierung [Nur CLI]

Überprüfe, ob erwarteter Text in der Cast-Datei erscheint:

def verify_recording(cast_file: Path) -> bool:
    content = cast_file.read_text()
    checks = [
        ("Sessions:",      "Act 1: stats label present"),
        ("authentication", "Act 4: search result shows keyword"),
    ]
    return all(fragment in content for fragment, _ in checks)

Deterministische Fixture-IDs [Nur CLI]

# IDs folgen einem erkennbaren Muster — leicht in Aufzeichnungen zu erkennen
_S1 = str(uuid.UUID("cafe0001-cafe-cafe-cafe-000000000001"))
# Stabile Tool-Call-IDs über verschiedene Läufe hinweg:
_id = hashlib.md5(f"{session_id}{timestamp}{path}".encode()).hexdigest()[:8]

Abschlussnachricht [Nur CLI]

# Lese den Installationsbefehl immer aus pyproject.toml oder README — rate nie
sys.stdout.write(
    "\033[1;32m  ══════════════════════════════════════\033[0m\n"
    "\033[1;32m  ✓  Demo complete — {tool name and tagline}\033[0m\n"
    "\033[1;32m  ══════════════════════════════════════\033[0m\n"
    "\n"
    "  Install:  {exact command from pyproject.toml/README}\n"
    "\n"
)

TUI-Pfad [Nur TUI]

Architektur: Warum tmux + Echte TUI

Die erste Arbeitsversion einer Demo zeigt typischerweise simulierte Ausgabe – der Harness ruft den Hook direkt auf und gibt aus, was der Hook sagen würde. Dies ist aus zwei Gründen falsch:

  1. Es ist nicht das, was Benutzer sehen. Benutzer sehen die vollständige interaktive TUI: Tool-Call-Blöcke, die konversationelle Antwort der KI, die in diese Antwort eingebettete Blocknachricht.
  2. Der Hook könnte überhaupt nicht gefeuert haben. Dies ist ohne Aufzeichnung tatsächlicher Tool-Calls nicht erkennbar.

Korrekte Architektur (TUI live):

Python-Harness
  → erstellt tmux-Sitzung
  → asciinema hängt an: asciinema rec --command "tmux attach-session -t SESSION"
  → Hintergrundthread: tmux send-keys "claude --dangerously-skip-permissions" Enter
  → pollt vertrauen/sicher

def tui_live_demo(session_name: str = "claude-demo"):
    """Starte eine TUI-Live-Dem-Sitzung."""
    subprocess.run(["tmux", "new-session", "-d", "-s", session_name])
    time.sleep(0.5)
    subprocess.run([
        "asciinema", "rec", "demo.cast",
        "--command", f"tmux attach-session -t {session_name}",
        "--window-size", "160x48"
    ], check=True)

Vergleich: TUI skriptiert vs. TUI live

EigenschaftTUI skriptiertTUI live
Treue zur BenutzererfahrungNiedrig (simuliert)Hoch (echte TUI)
AufzeichnungsgeschwindigkeitSchnellLangsam (echte Latenz)
FehleranfälligkeitGering (vorhersehbar)Höher (Timing-Probleme)
Beste fürSchnelle Prototypen, RegressionstestsFinale, veröffentlichungsreife Demos

TUI-Aufzeichnungshilfen [Nur TUI]

import subprocess
import time

def send_keys(session: str, keys: str, delay: float = 0.05):
    """Sende Tasten an eine tmux-Sitzung."""
    for ch in keys:
        subprocess.run(["tmux", "send-keys", "-t", session, ch])
        time.sleep(delay)
    subprocess.run(["tmux", "send-keys", "-t", session, "Enter"])

def wait_for_prompt(session: str, timeout: float = 30.0):
    """Warte auf eine sichtbare Eingabeaufforderung in der tmux-Sitzung."""
    start = time.time()
    while time.time() - start < timeout:
        result = subprocess.run(
            ["tmux", "capture-pane", "-t", session, "-p"],
            capture_output=True, text=True
        )
        if "$" in result.stdout or "%" in result.stdout or "❯" in result.stdout:
            return True
        time.sleep(0.5)
    return False

Ausgabe-Erfassung [Nur TUI]

def capture_tui_output(session: str) -> str:
    """Erfasse den aktuellen Inhalt einer tmux-Pane."""
    result = subprocess.run(
        ["tmux", "capture-pane", "-t", session, "-p", "-S", "-", "-E", "-"],
        capture_output=True, text=True
    )
    return result.stdout

Tool-Call-Überprüfung [Nur TUI]

def verify_tool_call(cast_file: Path, expected_tool: str, expected_args: dict) -> bool:
    """Überprüfe, ob eine Cast-Datei einen bestimmten Tool-Call enthält."""
    with open(cast_file) as f:
        for line in f:
            if line.startswith("["):
                try:
                    event = json.loads(line)
                    if isinstance(event, list) and len(event) == 3:
                        data = event[2]
                        if expected_tool in data and expected_args.get("key") in data:
                            # Vereinfachte Überprüfung — je nach Bedarf erweitern
                            return True
                except json.JSONDecodeError:
                    continue
    return False

Dateistruktur [Beide]

tests/
├── test_demo.py             # Demo-Harness & Tests
├── tool-demo/                # Synthetische Fixtures
│   ├── config.json
│   └── data/
│       ├── fixture1.jsonl
│       └── fixture2.jsonl
├── demo.cast                 # Roh-Aufzeichnung (von asciinema)
├── demo.gif                  # Konvertierte Ausgabe (von agg)
├── demo.mp4                  # Videoausgabe (von ffmpeg)
└── references/
    ├── common-pitfalls.md
    ├── rust-demos.md
    └── typescript-demos.md

Fehlerbehebung [Beide]

Cast-Datei ist nach der Aufzeichnung leer

  1. Prüfe, ob capture_output=False gesetzt ist
  2. Stelle sicher, dass asciinema den richtigen --command hat
  3. Überprüfe die Zeilenumbrüche in der Test-Harness

Banner wird nicht bereinigt

  1. Führe trim_cast_to_banner() nach der Aufzeichnung aus
  2. Verwende den richtigen banner_marker-String
  3. Manuelle Überprüfung: Suche nach "\033[H\033[2J" in der Cast-Datei

Ausgabe ist in GIF/MP4 abgeschnitten

  1. Erhöhe --window-size auf asciinema (z. B. 160x48)
  2. Überprüfe die Terminalbreite im Harness
  3. Verwende script -q -c "BEFEHL" /dev/null zur Breitenkontrolle

Farben erscheinen falsch in GIF

  1. Verwende --theme dracula oder passe das Theme an
  2. Stelle sicher, dass $TERM auf xterm-256color gesetzt ist
  3. Verwende --renderer fontdue für bessere Textqualität

Referenzen [Beide]

Siehe references/common-pitfalls.md für detaillierte Fehlerbehebung und references/rust-demos.md / references/typescript-demos.md für Implementierungen in diesen Sprachen.

Individual skills in this repo

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

Verwandte Skills