Claude
Skills
Sign in
Back

cli-demo-recorder

Included with Lifetime
$97 forever

Create demo videos for CLI and TUI tools. Use when user asks to "record a demo", "make a demo GIF", "create a demo video", "record my CLI tool", "asciinema demo", "demo recording script". Covers asciinema+agg+ffmpeg pipeline, tmux recording, multi-shell, and dual-purpose demo+test harnesses in Python, Rust, TypeScript, and Bash.

Image & Video

What this skill does


# 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)
1. **Read the tool's docs first** — before writing a single act
2. **Choose 5–7 features** that are visible, immediate, and self-explanatory to newcomers
3. **Skip invisible features** (background daemons, auto-save without visible output)
4. **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 records `python test_demo.py --run-acts`.
- **TUI scripted**: Python script that calls `run_hook()` directly for each act. asciinema records `python 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]
```bash
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
```

```python
# ✅ 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]

```python
_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:

```python
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]

```python
# 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.

```python
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.mk

Related in Image & Video