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-Typ | Interaktive TUI? | Verwendet KI/LLM? | Richtiger Pfad |
|---|---|---|---|
| Reines CLI (aise, git, curl) | Nein | Nein | CLI: Harness IST die Aufzeichnung |
| CLI + KI-Sitzung (claude -p) | Keine TUI | Ja | CLI: Überprüfen, ob Ausgabe nützlich ist |
| Plugin/Hook für TUI-Tool | Via TUI | Ja | TUI live: tmux + Pane |
| Plugin mit Hook-only-Aktionen | Via Hook | Nein | TUI skriptiert: run_hook() + --play |
| Startet interaktive TUI | Ja | Vielleicht | TUI live: TUI via tmux steuern |
| Batch-/Konfigurationstool | Nein | Nein | CLI: 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.)
- Lies zuerst die Doku des Tools – bevor du einen einzigen Act schreibst
- Wähle 5–7 Funktionen aus, die sichtbar, unmittelbar und für Neulinge selbsterklärend sind
- Überspringe unsichtbare Funktionen (Hintergrund-Daemons, automatisches Speichern ohne sichtbare Ausgabe)
- 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 zeichnetpython test_demo.py --run-actsauf. - TUI skriptiert: Python-Skript, das
run_hook()direkt für jeden Act aufruft. asciinema zeichnetpython test_demo.py --playauf. - TUI live: Python-Skript, das eine tmux-Sitzung erstellt, Eingabeaufforderungen via
send-keyssendet 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:
- 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.
- 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
| Eigenschaft | TUI skriptiert | TUI live |
|---|---|---|
| Treue zur Benutzererfahrung | Niedrig (simuliert) | Hoch (echte TUI) |
| Aufzeichnungsgeschwindigkeit | Schnell | Langsam (echte Latenz) |
| Fehleranfälligkeit | Gering (vorhersehbar) | Höher (Timing-Probleme) |
| Beste für | Schnelle Prototypen, Regressionstests | Finale, 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
- Prüfe, ob
capture_output=Falsegesetzt ist - Stelle sicher, dass asciinema den richtigen
--commandhat - Überprüfe die Zeilenumbrüche in der Test-Harness
Banner wird nicht bereinigt
- Führe
trim_cast_to_banner()nach der Aufzeichnung aus - Verwende den richtigen
banner_marker-String - Manuelle Überprüfung: Suche nach "\033[H\033[2J" in der Cast-Datei
Ausgabe ist in GIF/MP4 abgeschnitten
- Erhöhe
--window-sizeauf asciinema (z. B.160x48) - Überprüfe die Terminalbreite im Harness
- Verwende
script -q -c "BEFEHL" /dev/nullzur Breitenkontrolle
Farben erscheinen falsch in GIF
- Verwende
--theme draculaoder passe das Theme an - Stelle sicher, dass
$TERMaufxterm-256colorgesetzt ist - Verwende
--renderer fontduefü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.