From ar
Records polished demo videos and GIFs for CLI and TUI tools via asciinema+agg+ffmpeg, tmux sessions, and harnesses in Python, Rust, TypeScript, or Bash.
npx claudepluginhub ahundt/autorun --plugin arThis skill uses the workspace's default tool permissions.
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).
references/alternative-tools.mdreferences/cast-merging.mdreferences/common-pitfalls.mdreferences/examples/aise-cli-example.mdreferences/examples/autorun-tui-example.mdreferences/hook-based-tools.mdreferences/prompt-engineering.mdreferences/rust-demos.mdreferences/tmux-integration.mdreferences/typescript-demos.mdtests/test_demo_skill_compliance.pyCaptures demo reels (GIFs, terminal recordings, screenshots) of UI changes, CLI features, or product behaviors for PR descriptions. Detects project type, handles secrets, uploads publicly, and provides markdown.
Generates animated GIF recordings of terminal sessions from VHS tape files. Validates tapes, checks/installs VHS, executes recordings, verifies outputs. For demos and tutorials.
Records polished UI demo videos of web apps using Playwright. Triggers for demo, walkthrough, screen recording, or tutorial requests. Uses discover-rehearse-record process with cursor overlay and WebM output.
Share bugs, ideas, or general feedback.
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"
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.
subprocess.run(..., capture_output=False). asciinema records python test_demo.py --run-acts.run_hook() directly for each act. asciinema records python test_demo.py --play.send-keys, and asciinema attaches to that session.python tests/test_demo.py --record → checks cast text fragmentspython tests/test_demo.py --record → parse JSONL for tool callsagg 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.
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)
_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()
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")
# 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)}
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.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
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 for narrow tools
"--capture-env", "TERM,COLORTERM,TOOL_ISOLATION_VAR",
], env=record_env, check=True)
finally:
shutil.rmtree(dated_dir, ignore_errors=True)
Asciinema records shell initialization (prompt, env vars, RC files) before the harness starts. This produces a messy first frame with shell artifacts instead of a clean banner. Fix this with a post-recording trim that finds the banner marker, walks back to the preceding clear-screen escape, drops all prior events, and rebases timestamps to t=0.
def trim_cast_to_banner(cast_file: Path, banner_marker: str = "mytool") -> None:
"""Trim shell init events so the banner is the first visible frame.
Modifies cast_file in place. Preserves the JSON header line.
"""
lines = cast_file.read_text().splitlines()
if len(lines) < 2:
return
header = lines[0] # JSON header — always line 1
events = lines[1:]
# Find the first event containing the banner marker text.
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
# Walk back to the clear-screen escape just before the 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
# Rebase timestamps: first kept event becomes 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)})")
Call after recording, before conversion: trim_cast_to_banner(CAST_FILE, banner_marker="mytool_name").
For Rust and TypeScript implementations, see references/rust-demos.md and references/typescript-demos.md.
Root cause of test/demo drift: writing tests independently of demo acts.
✅ Correct workflow:
1. Finalize run_demo_acts() — fix the EXACT command string for each act
2. Write TestDemoFree using those SAME command strings, verbatim
3. Never add --format, --limit, or extra flags to tests that aren't in the demo
❌ Wrong workflow:
demo act: mytool messages search keyword --context 1
test: mytool messages search keyword --format plain --full-uuid
→ test passes, demo shows different output, test proves nothing about the demo
Rule: if a test needs a different flag to verify output, either (a) add that flag to the demo act too, or (b) verify a property that the demo's actual output satisfies.
Check that expected text appears in the cast file:
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)
# IDs follow a recognizable pattern — easy to spot in recordings
_S1 = str(uuid.UUID("cafe0001-cafe-cafe-cafe-000000000001"))
# Stable tool-call IDs across runs:
_id = hashlib.md5(f"{session_id}{timestamp}{path}".encode()).hexdigest()[:8]
# Always read install command from pyproject.toml or README — never guess
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"
)
The first working version of a demo typically shows simulated output — the harness calls the hook directly and prints what the hook would say. This is wrong for two reasons:
Correct architecture (TUI live):
Python harness
→ creates tmux session
→ asciinema attaches: asciinema rec --command "tmux attach-session -t SESSION"
→ background thread: tmux send-keys "claude --dangerously-skip-permissions" Enter
→ polls trust/safety dialog → auto-confirms with Enter
→ polls for TUI input prompt (❯) — requires 3 consecutive idles
→ tmux send-keys "{prompt}" Enter
→ waits 1.5s, then polls until 3 consecutive idles
→ next act...
→ claude exits → tmux session ends → asciinema finishes
Background thread pattern (TUI live recording):
demo_t = threading.Thread(target=_demo_thread, daemon=True)
demo_t.start()
# asciinema in foreground records the tmux session
asciinema_proc = subprocess.Popen([asciinema, "rec", cast_file,
"--command", f"tmux attach-session -t {session.session_name}", ...])
demo_t.join(timeout=600)
time.sleep(1)
asciinema_proc.terminate()
Wrong for TUI tools:
# ❌ Headless subprocess — output not shown in TUI, not recorded by asciinema
result = subprocess.run(["mytool", "--do-thing"], capture_output=True)
# ❌ claude -p stays on command line, no TUI, asciinema captures nothing useful
subprocess.run(["claude", "-p", "delete this file"])
For tools with hook-level acts (no Claude TUI needed), use scripted mode:
run_hook() subprocess directlypython test_demo.py --play as the command# Scripted recording: asciinema records this process directly
asciinema_cmd = [asciinema, "rec", cast_file, "--overwrite",
"--command", f"{sys.executable} {Path(__file__)} --play",
"--idle-time-limit", "3"] # shorter idle limit for scripted mode
subprocess.run(asciinema_cmd)
Use a short, fixed path — not tempfile.TemporaryDirectory(). Long paths appear in every tool call and look unprofessional.
# ❌ Long noisy path in every tool call:
work_dir = Path(tempfile.mkdtemp())
# → /private/var/folders/9f/jr_p974d3j318tmvrfkjl55w0000gp/T/tmpAbcDef
# ✅ Clean, short path:
import os
work_dir = Path(f"/tmp/mytool-demo-{os.getpid()}")
work_dir.mkdir(parents=True, exist_ok=True)
def setup_mock_project(work_dir: Path) -> None:
# Use GIT_CONFIG_NOSYSTEM to prevent system git config bleed-in
env = {**os.environ, "GIT_CONFIG_NOSYSTEM": "1"}
run = lambda cmd: subprocess.run(cmd, cwd=work_dir, capture_output=True, env=env)
run(["git", "init"])
run(["git", "config", "user.email", "demo@example.dev"])
run(["git", "config", "user.name", "Demo User"])
run(["git", "config", "commit.gpgsign", "false"]) # required — signing may be global
(work_dir / "main.py").write_text(
"#!/usr/bin/env python3\n\n# FIXME: add input validation\ndef main():\n pass\n"
)
(work_dir / "config.yaml").write_text("debug: false\nversion: 1.0\n")
(work_dir / "project_data.csv").write_text("id,name\n1,important\n2,data\n")
(work_dir / "auth.py").write_text(
"# WIP: refactoring\ndef login(user, pwd):\n pass # stub\n"
)
run(["git", "add", "main.py", "config.yaml"])
run(["git", "commit", "-m", "initial commit"])
# Leave auth.py and project_data.csv uncommitted for demo acts
Pane index must be queried, not hardcoded:
# ❌ Breaks when tmux base-index=1 (common configuration):
pane = "session_name:0.0"
# ✅ Query actual window and pane index after creating the session:
result = subprocess.run(
["tmux", "list-panes", "-t", session_name, "-F", "#{window_index}:#{pane_index}"],
capture_output=True, text=True
)
pane = f"{session_name}:{result.stdout.strip()}"
Sending special characters: use -l flag (literal):
# ❌ tmux interprets / as a search, { as repeat count:
subprocess.run(["tmux", "send-keys", "-t", pane, "/ar:plannew"])
# ✅ -l flag sends text literally without tmux special-char interpretation:
subprocess.run(["tmux", "send-keys", "-t", pane, "-l", text])
# Required for: /command, {text}, !, and any prompt with special chars
Kill stale asciinema before re-recording:
# Without this: re-running --record with the same session name produces two
# asciinema processes writing to the same .cast file simultaneously (corrupt cast).
import signal
result = subprocess.run(["pgrep", "-f", f"asciinema.*{session_name}"],
capture_output=True, text=True)
for pid_str in result.stdout.strip().splitlines():
try:
os.kill(int(pid_str.strip()), signal.SIGTERM)
except (ValueError, ProcessLookupError):
pass
Detecting the CLI's input prompt:
# ❌ Looks for ASCII > — misses Claude Code's Unicode ❯ prompt:
if ">" in pane_content:
return True
# ✅ Check for Unicode prompt AND a reasonable idle state:
prompts = ["❯", ">", "$ ", "% "]
if any(p in pane_content for p in prompts):
return True
Confirming trust/safety dialogs:
# ❌ Exact string breaks if dialog wording changes between versions:
if "Yes, I trust this folder" in content:
send_key("Enter")
# ✅ Keyword detection survives wording changes:
trust_keywords = ["trust", "safe", "quick safety check", "allow"]
if any(kw in content.lower() for kw in trust_keywords):
send_key("Enter")
Preventing false "done" detection:
# ❌ One idle check gives false positives — AI briefly shows input prompt
# between chained tool calls (tool A finishes → shows ❯ → calls tool B)
if is_at_input_prompt(pane):
return # premature!
# ✅ Require 3 consecutive idle checks (~1.5s total) to confirm truly done.
# Also: sleep 1.5s FIRST to let Claude begin responding.
time.sleep(1.5)
idle_count = 0
while True:
if is_at_input_prompt(pane):
idle_count += 1
if idle_count >= 3:
return
else:
idle_count = 0
time.sleep(0.5)
Plan approval is a different prompt state — do not use wait_for_response():
# ❌ Wrong: wait_for_response() doesn't recognize plan approval UI
session.send_prompt("/mytool:plannew Add input validation to auth.py")
session.wait_for_response(timeout=300) # may hang or return early
session.send_prompt("/mytool:planrefine") # sent before plan was accepted!
# ✅ Right: use a separate wait that detects the plan approval prompt
session.send_prompt("/mytool:plannew Add input validation to auth.py")
session.wait_for_plan_approval(timeout=300)
pause(7.0) # let viewers read the plan
session.approve_plan() # dynamically finds and presses the right option
pause(3.0)
session.send_prompt("/mytool:planrefine")
Choosing which plan option to press — do not hardcode the number:
# ❌ Wrong: hardcoded — breaks when menu reorders
session._send_key("2\n") # "2" may mean "clear context" in some versions
# ✅ Right: parse actual menu; use exact word sets from autorun DemoSession
_ACCEPT_WORDS = ("yes", "proceed", "accept", "bypass")
_CLEAR_WORDS = ("clear context", "new conversation", "fresh context", "clear history")
# Regex handles ❯ cursor prefix:
m = re.match(r'[❯\s]*(\d+)\.\s+(.+)', stripped_line)
# Select line with accept word AND no clear word; fallback to "1"
Shell command overlap:
# ❌ Fixed sleep may not be enough if previous command runs longer:
session.send_command("python3 banner.py")
time.sleep(1.0)
session.send_command("claude") # overlap!
# ✅ Wait for shell prompt after each command:
session.send_command("python3 banner.py")
wait_for_shell_prompt(pane, timeout=10)
session.send_command("claude")
Run the banner as a script inside the tmux pane — not from the Python harness (it won't appear in the asciinema recording):
_BANNER_SCRIPT = r'''#!/usr/bin/env python3
import sys
CYAN, BOLD, RESET, GRAY = "\033[96m", "\033[1m", "\033[0m", "\033[90m"
W = 70 # compute padding on PLAIN text — no ANSI in len() math
def pad(text): return text + " " * (W - len(text) - 2)
lines = [
("", ""),
(f" ╔{'═'*W}╗", CYAN),
(f" ║ {pad('mytool — safety plugin for Claude Code + Gemini CLI')}║", CYAN),
(f" ╠{'═'*W}╣", CYAN),
(f" ║ {pad('Install once. Runs silently in the background.')}║", CYAN),
(f" ║ {pad('This demo shows:')}║", CYAN),
(f" ║ {pad('1. Dangerous commands blocked + safe redirect')}║", CYAN),
(f" ║ {pad('2. File policy — restrict to existing files only')}║", CYAN),
(f" ╚{'═'*W}╝", CYAN),
("", ""),
]
for text, color in lines:
sys.stdout.write(color + text + RESET + "\n")
sys.stdout.flush()
'''
def act0_live(session, tmp_dir):
banner_path = tmp_dir / "_demo_banner.py"
banner_path.write_text(_BANNER_SCRIPT)
session.run_shell_cmd(f"python3 {banner_path}; rm {banner_path}", wait=1.0)
pause(10.0) # Let viewers read all items — this is the only chance
session.run_shell_cmd("mytool --status", wait=2.0)
pause(6.0)
For TUI demos where hooks must fire, prompt engineering is the core skill. The cause of a hook not firing is always the prompt or context — not the tool's behavior.
Key techniques (full guide in references/prompt-engineering.md):
rm, sed, git clean -f) over conditional onesEvery act must answer "what just happened and why does it matter?" for someone who has never seen your tool before.
Good act structure:
CLI acts = section(title) + _run(cmd) + pause(N) pairs.
TUI acts = session.send_prompt(text) + session.wait_for_response() + pause(N) pairs.
Features to show: Dangerous command blocked + redirect. Policy/mode toggle. Custom rule lifecycle. Auto-saved artifact with ls immediately after.
Features to skip: Background daemon operations. Multi-window features. Requires prior tool knowledge. Config file editing.
def pause(seconds: float) -> None:
"""Sleep if in timed mode; no-op in pytest."""
if _TIMED: # or _DEMO_WITH_TIMING — both are the same concept, different names
time.sleep(seconds)
# Standard timing:
pause(2.0) # Before every prompt — let viewers finish reading previous response
pause(7.0) # After short response — let viewers read the block message
pause(10.0) # After complex response — plan creation, long output
pause(10.0) # After intro banner — all items must be readable
pause() ≠ section() spacing — they are independent knobs:
pause(N) = reading time. Change when viewers can't finish reading.section() \n\n\n before bar = visual gap between acts. Change when acts look crammed.#!/usr/bin/env python3
"""
Demo harness — dual purpose:
CLI: python tests/test_demo.py --record (records asciinema cast)
TUI: python tests/test_demo.py --record (asciinema attaches to tmux)
Pytest: pytest tests/test_demo.py::TestDemoFree # $0.00 always
pytest tests/test_demo.py::TestDemoRealMoney # requires opt-in
"""
import pytest, argparse
@pytest.mark.skipif(
not os.environ.get("DEMO_ENABLE_REAL_MONEY"),
reason="Set DEMO_ENABLE_REAL_MONEY=1 to run live Claude tests"
)
class TestDemoRealMoney: ...
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--record", action="store_true", help="Record to .cast → GIF/MP4")
parser.add_argument("--run-acts", action="store_true", help="Run acts (CLI: recorded command)")
parser.add_argument("--play", action="store_true", help="Scripted acts, no AI ($0.00)")
parser.add_argument("--gif-only", action="store_true", help="Convert existing cast to GIF")
parser.add_argument("--verify", action="store_true", help="Verify last recording")
parser.add_argument("--setup", action="store_true", help="Regenerate test fixtures")
parser.add_argument("--no-cleanup",action="store_true", help="Keep tmux session (debug)")
args = parser.parse_args()
--no-cleanup: Keep tmux session alive after demo finishes. Critical for diagnosing why a hook didn't fire.
Tool dependencies must be optional: asciinema, agg, ffmpeg are NOT project dependencies:
agg_bin = shutil.which("agg") or shutil.which("/tmp/agg")
if not agg_bin:
print("[demo] agg not found — cast recorded but GIF skipped")
print("[demo] Install: brew install agg or cargo install agg")
return
ffmpeg_bin = shutil.which("ffmpeg")
if not ffmpeg_bin:
print("[demo] ffmpeg not found — MP4 skipped (GIF only)")
gitignore output files:
*.gif
*.cast
*.mp4
*.webm
class TestDemoFree:
"""$0.00 — verify behavior without a live AI session."""
# CLI: run exact demo commands against DEMO_ENV fixtures
def test_stats_output(self):
result = subprocess.run("mytool stats", env=DEMO_ENV, shell=True,
capture_output=True, text=True)
assert "Sessions:" in result.stdout
# TUI: call run_hook() directly
def test_rm_blocked(self, tmp_path):
rc, _, stderr = run_hook("Bash", {"command": "rm file.txt"}, root)
assert rc == 2
assert "trash" in stderr.lower()
Acts first, then tests — finalize each act's exact command string, then write tests that run the same command verbatim. Never write tests first and let them drift from the demo.
Dry-run each act against fixtures BEFORE writing assertions — verify the expected output actually appears. Don't assume fixture data will trigger behavior.
def find_tool_root() -> Path:
candidates = [
Path(__file__).parent.parent, # tests/ → tool/
Path.home() / ".tool" / "current",
]
cache_base = Path.home() / ".cache" / "tool" / "versions"
if cache_base.is_dir():
for version_dir in sorted(cache_base.iterdir(), reverse=True):
if (version_dir / "pyproject.toml").exists():
candidates.append(version_dir)
break
for c in candidates:
if (c / "pyproject.toml").exists():
return c
raise RuntimeError(f"Tool not found. Searched: {candidates}")
| Quality | Terminal | Font | Approx Output | Use Case |
|---|---|---|---|---|
| Full HD (1080p) | 160x48 | 18 | ~1750x1230 | GitHub README, presentations |
| 2K | 160x48 | 20 | ~1960x1380 | High-DPI displays |
| Minimum viable | 160x48 | 16 | ~1560x1098 | Acceptable for web |
| NOT acceptable | 80x24 | any | <1000px wide | Too small for text readability |
Rule: Never use 80x24 for demo recordings. Minimum 160x48 cols/rows with font-size 16+.
Note: --idle-time-limit appears in two contexts with different values:
asciinema rec flag: limits idle gaps DURING recording (scripted: 3s; TUI live: 5s)agg flag: limits idle gaps IN GIF output (both pathways: 10s — preserves banner)| Setting | CLI | TUI scripted | TUI live |
|---|---|---|---|
--window-size (asciinema) | 160x48 | auto from terminal | {cols}x{rows} |
--idle-time-limit (asciinema) | — | 3 | 5 |
--font-size (agg) | 16-18 | 16-18 | 16-18 |
--speed (agg) | 0.75 | 0.75 | 0.75 |
--idle-time-limit (agg) | 10 | 10 | 10 |
--last-frame-duration (agg) | 5 (hold closing frame) | — | — |
--renderer fontdue | yes | yes | yes |
--theme dracula | yes | yes | yes |
MP4 codec strategies (4-strategy fallback, best first):
libx265 crf=28 tune=animation — HEVC, ~50% smaller than libx264, needs tag:v hvc1 for macOS. Note: HEVC not supported in Firefox; use libx264 if embedding in web pageslibx264 crf=24 tune=animation — broadest browser compat (Chrome, Firefox, Safari); CRF 24 in x264 gives similar visual quality to CRF 28 in x265h264_videotoolbox -q:v 65 — macOS hardware encoder, VBR, fast but larger filesffmpeg default — last resortCRF note: CRF 24 in libx264 and CRF 28 in libx265 produce approximately equivalent visual quality. Both are visually lossless for terminal text. Use tune=animation for terminal recordings (flat colors + sharp edges) — do not use tune=fast.
Settings that did not work:
--font-size 20 at 160x48: text too large, content gets cut off at edges--speed 1.5: too fast for viewers to read block messages--idle-time-limit 5 (agg): banner and status pauses get cut short in GIFSee references/common-pitfalls.md for the full 30+ pitfall table covering CLI, TUI, and shared issues including recording failures, timing problems, prompt engineering failures, and encoding pitfalls.
Working examples with act lists, design decisions, prompt evolution, and recording parameters:
references/examples/aise-cli-example.md — aise (ai_session_tools), 7 CLI actsreferences/examples/autorun-tui-example.md — autorun for Claude Code, 7 TUI acts with prompt debugging history| Tool | Purpose | Install | Required for |
|---|---|---|---|
asciinema | Record terminal session to .cast | brew install asciinema | Both |
agg | Convert .cast → animated GIF | brew install agg or cargo install agg | Both |
ffmpeg | Convert GIF → MP4 | brew install ffmpeg | Both |
tmux | Full TUI session isolation and control | brew install tmux | TUI only |
| File | Content |
|---|---|
references/hook-based-tools.md | Forcing Bash tool calls for hook-based tool demos |
references/common-pitfalls.md | 30+ catalogued pitfalls with root causes and fixes |
references/prompt-engineering.md | TUI prompt strategies for reliable AI tool invocation |
references/rust-demos.md | Rust demo harness patterns (AtomicBool, multi-shell, cargo bins) |
references/typescript-demos.md | TypeScript/Node demo patterns (spawn, Vitest) |
references/tmux-integration.md | tmux recording patterns (multi-pane, multi-shell, idle detection) |
references/cast-merging.md | Merging multiple .cast files into single recordings |
references/alternative-tools.md | VHS, t-rec, and other recording tools vs asciinema |
references/examples/aise-cli-example.md | Working CLI demo: aise (7 acts, privacy isolation) |
references/examples/autorun-tui-example.md | Working TUI demo: autorun (7 acts, prompt evolution) |
v5.1.0 — 2026-03-11
trim_cast_to_banner() removes shell init artifacts from .cast first framereferences/common-pitfalls.mdv5.0.0 — 2026-03-11
version field to frontmatterreferences/common-pitfalls.mdreferences/prompt-engineering.mdreferences/examples/references/rust-demos.md — Rust harness patterns from canal (AtomicBool, multi-shell, cargo bins)references/typescript-demos.md — TypeScript/Node harness patterns (spawnSync, Vitest)references/tmux-integration.md — multi-pane, multi-shell, idle detection, trust dialogsreferences/cast-merging.md — asciinema cat, programmatic merging, transitionsreferences/alternative-tools.md — VHS (charmbracelet) and t-rec comparisonv4.0.0 — 2026-03-05
_run(), _TIMED/_DEMO_WITH_TIMING (same concept, two names), pause(), section() for CLIpause() timing ≠ section() newline spacing — independent knobsTOOL_ISOLATION_VARDEMO_ENV + generic TOOL_ISOLATION_VAR pattern--run-acts / --gif-only / --verify / --setup / --play CLI flag patternsrun_hook() directlysend-keys -l (literal flag) to TUI plumbing — required for slash commands and braces_kill_stale_recording_procs() — prevents corrupt casts on re-recordGIT_CONFIG_NOSYSTEM=1 + commit.gpgsign=false to mock git repo setup--idle-time-limit rowslibx265 tune=animation as strategy 1 in MP4 fallback (4 strategies total)section() auto-sizing improvement: replace hardcoded width with max(68, len(title) + 6)v3.0.0 — 2026-03-01
v2.0.0 — 2026-03-01
v1.0.0 — 2026-03-01