CLI Demo Recorder
Record polished demo videos for CLI tools — either as direct subprocess recordings (CLI tools, no AI session) or real TUI recordings (interactive AI sessions via tmux).
Use this skill when: A demo GIF/video is needed for a CLI tool, plugin, or terminal application.
Invoke with: /cli-demo-recorder or "Help me record a demo for my CLI tool"
Choose Your Pathway [Both]
Pick the pathway based on whether the tool has an interactive TUI session. Wrong choice → recording captures nothing useful.
| Tool type | Interactive TUI? | Uses AI/LLM? | Correct pathway |
|---|---|---|---|
| Pure CLI (aise, git, curl) | No | No | CLI: harness IS the recording |
| CLI + AI session (claude -p) | No TUI | Yes | CLI: verify output is useful |
| Plugin/hook for TUI tool | Via TUI | Yes | TUI live: tmux + pane |
| Plugin with hook-only acts | Via hook | No | TUI scripted: run_hook() + --play |
| Spawns interactive TUI | Yes | Maybe | TUI live: drive TUI via tmux |
| Batch/config tool | No | No | CLI: capture_output=False |
WARNING: Using subprocess.run(capture_output=True) for a CLI demo silences recording entirely — asciinema captures nothing. See CLI pathway for the correct pattern.
How It Works [Both]
Phase 1: Plan (15–30 min)
- Read the tool's docs first — before writing a single act
- Choose 5–7 features that are visible, immediate, and self-explanatory to newcomers
- Skip invisible features (background daemons, auto-save without visible output)
- Choose your pathway (see table above) — this determines the entire harness design
Phase 2: Build the Harness (30–60 min)
- CLI: Python script that calls
subprocess.run(..., capture_output=False). asciinema recordspython test_demo.py --run-acts. - TUI scripted: Python script that calls
run_hook()directly for each act. asciinema recordspython test_demo.py --play. - TUI live: Python script that creates a tmux session, sends prompts via
send-keys, and asciinema attaches to that session.
Phase 3: Record and Verify (10–30 min)
- CLI:
python tests/test_demo.py --record→ checks cast text fragments - TUI:
python tests/test_demo.py --record→ parse JSONL for tool calls
Phase 4: Convert [Both]
agg demo.cast demo.gif \
--theme dracula \
--font-size 14 \ # 14-16; smaller fits more content
--renderer fontdue \ # vector-quality anti-aliased text
--speed 0.75 \ # 0.75x — readable without pausing
--idle-time-limit 10 # 10s — preserves full banner display
# MP4: 4-strategy fallback (best compression first)
# Strategy 1: libx265 HEVC (tune=animation — ~50% smaller than libx264 at same quality)
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
Important: Convert GIF → MP4 (not cast → MP4). The GIF is already processed; going cast → MP4 directly misses the speed/idle adjustments from agg. Use tune=animation (not tune=fast) — terminal recordings have flat colors and sharp edges that match animation compression.
Total: ~75–120 minutes for a polished, verified demo.
CLI Pathway [CLI Only]
Architecture: Harness IS the Recording
For CLI tools, the Python harness runs as the command that asciinema records. No tmux, no pane attachment.
asciinema rec demo.cast --command "python test_demo.py --run-acts"
↑ harness runs here
→ harness types $ prompt, runs subprocess with capture_output=False
→ asciinema captures all stdout from the process
→ agg demo.cast demo.gif
# ✅ CORRECT — output flows to terminal, asciinema captures it
subprocess.run("mytool subcommand", shell=True, capture_output=False, env=DEMO_ENV)
# ❌ WRONG for CLI demos — captures output into Python, asciinema sees nothing
result = subprocess.run(["mytool", "subcommand"], capture_output=True)
Core Helpers [CLI Only]
_TIMED = False # True only inside --run-acts (recording mode); _DEMO_WITH_TIMING also common
def pause(seconds: float) -> None:
"""No-op in pytest; sleeps during recording. Errors 1+2: timing ≠ spacing."""
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:
"""Show typed $ prompt, then run command.
capture_output=False is CRITICAL — output must flow to 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:
"""Visual section divider between acts.
3 newlines BEFORE bar = visual gap from previous act (change this for spacing).
pause() durations = reading time (change separately for timing).
These are INDEPENDENT knobs — do not conflate them.
bar_len = max(68, len(title) + 6) prevents bars shorter than title.
"""
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()
Intro Banner [CLI Only]
For CLI tools, the banner is a Python string printed directly to stdout:
def banner() -> None:
W = 68 # compute padding on PLAIN text only — no ANSI codes inside len() math
def row(text: str = "", style: str = "") -> str:
content = (" " + text).ljust(W) # W visible chars; no ANSI in length
return f"\033[90m ║\033[0m{style}{content}\033[0m\033[90m║\033[0m"
# ❌ WRONG — ANSI codes inflate len(), misalign padding:
# 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")
Privacy Isolation [CLI Only]
# DEMO_DATA_DIR: committed synthetic fixtures (no real user data)
# TOOL_ISOLATION_VAR: env var that redirects the tool's data reads
# Examples: 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)}
Date-Shifting Fixtures [CLI Only]
Required when demo acts use --since Nd, --after DATE, or any time-relative filter.
def create_dated_demo_dir() -> Path:
"""Copy DEMO_DATA_DIR to temp dir with timestamps shifted to near today.
Without this: fixtures from months ago → 0 results for --since 3d.
The committed fixture files are NEVER modified — only the temp copy is shifted.
Adapt _TS_RE and shift logic to match your tool's timestamp format.
"""
_TS_RE = re.compile(r'"timestamp":\s*"(\d{4}-\d{2}-\d{2}T[^"]+)"')
# find max timestamp in fixtures, compute delta to (today - 1 day)
tmp = Path(tempfile.mkd