| name | vhs-cli-demos |
| description | Generate deterministic screenshots and demo GIFs of command-line and terminal (TUI) applications using Charm VHS. Use this skill whenever the user wants to capture, record, screenshot, or make a GIF/video of a CLI or terminal app — for a README, docs site, marketing page, changelog, release notes, or visual regression tests. Trigger on phrases like "record a gif of my CLI", "demo gif for the README", "screenshot my terminal app", "capture the TUI", "make a terminal recording", "VHS tape", "charm vhs", "asciinema but as a gif", or any request to show a terminal program in motion or as a still — even when the user doesn't name VHS. Also use when a captured GIF is too large and needs to be shrunk for the web, or when setting up a repeatable capture pipeline for many scenes. Covers install, authoring tapes, determinism, motion-GIF storytelling, and the lossless size-optimization that makes GIFs web-viable. |
Capturing CLI & TUI apps with VHS
VHS (by Charm) drives a real PTY through a
headless terminal and records exactly what a user would see — so captures are
pixel-accurate, scriptable, and reproducible in CI. It's the right tool for
README GIFs, docs/marketing stills, release demos, and terminal visual-regression
tests. This skill is the methodology; it transfers to any CLI or TUI regardless
of language (Go, Rust, Python, Node, a shell script — VHS only sees the terminal).
The single most important thing to internalize: a .tape is a screenplay, not
a config file. You're directing a short scene — what's typed, how long each
beat holds, when the camera clicks. Two failure modes dominate, and both are
covered below: captures that look wrong (loading spinners, drifting dates, empty
state — a determinism problem) and GIFs that are enormous (10-20 MB — a
file-size problem with a one-line lossless fix).
This skill assumes the interface being captured already looks good. If the user
is still shaping the TUI/CLI itself — layout, keybindings, color, what a screen
should even show — that's the tui-design skill's job; design it there first,
then come back here to capture it.
The essentials (if you read nothing else)
Three things separate a capture that ships from one that embarrasses you. Get
these right and the rest is polish:
- Always optimize GIFs losslessly. Raw VHS GIFs are 10-20 MB of redundant
frames. Run
gifsicle -O3 (lossless, 20-30× smaller, zero quality loss) — via
the bundled scripts/optimize_gif.sh — or bake it into your pipeline. Never
ship a raw VHS GIF, and never reach for lossy compression as the default fix.
- Make it deterministic, and size it generously. Pin the clock, lock the
theme, pick a readable
FontSize, and settle long enough for cold-start.
Leave Set Width/Set Height alone and let VHS use its roomy defaults: a
too-small capture renders fine on its own but turns soft and blurry the
moment it's scaled up to fill a README or docs column. (See Determinism.)
- Use the right output.
Screenshot file.png for a single still; Output file.gif/.mp4 for motion. Output file.png is a trap — it records a frame
sequence, not an image.
Everything below explains the why and the edge cases. When in doubt, those
three are the load-bearing ones.
When to read which reference
Keep this body in context for the workflow and the hard-won lessons. Load a
reference only when you reach that step:
references/tape-reference.md — the full VHS tape DSL (Set, Type,
Sleep, Key, Hide/Show, Output, Screenshot, Source, Require),
output formats (gif/mp4/webm/png), and complete annotated example tapes for
both a still and a motion demo. Read it when authoring a non-trivial tape or
when you need a command you don't remember.
references/recipe-catalog-pattern.md — how to graduate from hand-written
one-off tapes to a generated recipe catalog + driver when a project needs
many captures kept in sync (the pattern behind a mature pipeline: typed recipe
list, scenario fixtures, theme-palette mapping, a sync step to a docs/site
folder). Read it when the user has more than a handful of captures or wants a
repeatable npm run screenshot-style workflow. Pairs with
assets/recipe-catalog-starter/ — a copy-pasteable implementation of the
whole pattern, not just a description of it.
references/recipe-catalog-advanced.md — techniques that only show up
once a catalog reaches production scale (100+ recipes): mocking an external
CLI a view depends on (gh/glab), an authentic cold-boot hero GIF instead
of always hiding startup, decoupling the on-camera command from the real one,
pinning a fast model for deterministic AI-response timing, and generating a
theme gallery programmatically instead of hand-writing each variant. Read it
once the base recipe-catalog pattern feels natural and a real need for one of
these shows up.
Setup
brew install vhs
brew install gifsicle
Verify with vhs --version. If vhs new demo.tape errors about ttyd/ffmpeg,
install those explicitly (brew install ttyd ffmpeg).
The core workflow
- Pick the output: a still PNG (
Screenshot) or a motion GIF (Output foo.gif). Stills are for docs/feature shots; GIFs for workflows in motion.
- Write the tape — the scene. Start from
scripts/new_tape.sh demo [--gif|--still], which scaffolds a tape with the determinism defaults below
already filled in (rather than bare vhs new, which has none of them), or
the annotated examples in the tape reference.
- Make it deterministic — pin time, lock the theme, pick a readable font
size, give the app time to settle (see Determinism). This is what separates a
capture you can regenerate from one you got lucky with once.
- Render:
vhs demo.tape. Inspect the output. Iterate on timing.
- For GIFs, optimize losslessly — run
scripts/optimize_gif.sh out.gif
(or wire gifsicle -O3 into your pipeline). This is not optional for the web.
Determinism — the thing that makes captures reusable
A capture is only useful if it looks identical every run. Control the sources of
drift up front:
- Wall-clock / relative dates. Anything that renders "3 days ago" or today's
date will drift between runs. If the app honors an env var or flag for a fixed
"now" (many do for exactly this reason), set it in the tape via
Env or an
exported variable. If it doesn't, consider adding one — it's the cleanest fix.
- Sizing — pick a
FontSize, then leave the canvas alone. VHS sizes the
output from Set Width/Set Height in pixels; the terminal renders its
character grid at the font's natural cell width, left-aligned, and just margins
any leftover space (it does not stretch glyphs to fill a too-wide canvas). So
the canvas pixel count doesn't change how the text looks at its own size. What
bites is scale: a small capture renders crisp but goes soft and blurry the
moment a README or docs page scales it up to fit a column. The fix is to capture
big enough that no upscaling is needed: set a readable FontSize (≈18-22) and
omit Width/Height so VHS uses its roomy defaults (1200×600). Only set
explicit dimensions for a specific reason (a fixed canvas across a set), and
then keep them generous — undersizing, not pixel-grid mismatch, is the trap.
(Determinism is preserved either way: same FontSize + same content → same
render.)
- Lock the theme. Pass the app's theme flag and set VHS's terminal palette to
match (
Set Theme). Don't rely on ambient terminal colours.
- Disable animations during the shot. Spinners, idle-tip rotations, blinking
cursors (
Set CursorBlink false) all introduce frame-to-frame noise. Wait for
loading states to settle before the Screenshot.
- Settle for interpreter cold-start. Inside VHS the app boots in a fresh
process; interpreted runtimes (tsx/ts-node, Python, a slow node entrypoint)
can take 2-3s to cold-start versus ~500ms warm, plus any async data load. If
shots show "loading…" or empty state, the fix is almost always more sleep
before the first capture, not a different command.
VHS shell-environment gotchas
VHS spawns a clean shell that does not inherit your parent environment. These
bite everyone once:
- Unquoted
$PATH in exports. Type types literal characters. If you write
Type "export PATH='...:$PATH'", the single quotes make bash treat $PATH as
a literal string and the shell loses git, sleep, and friends. Export with
the value unquoted so $PATH expands: export PATH=/your/bin:$PATH.
- Forward the env you need. API keys, tokens, or config the app reads aren't
present in the VHS shell. Export them inside the tape (use
Hide/Show so the
export lines don't appear on camera) or pass them through your driver.
Screenshot vs Output. Output "x.png" records the whole session as a
frame sequence (a directory) — not a single image. For one still frame use
Screenshot x.png (bare filename). Output "x.gif" / "x.mp4" is for motion.
- cwd binding. VHS's
cd/Type "cd …" changes the shell cwd, but some apps
bind their working context another way (e.g. a --repo/--cwd flag that calls
chdir internally). Pass the explicit flag rather than trusting cwd.
- macOS temp-dir symlinks.
/var/folders/... is really /private/var/...;
tools with path-safety checks (e.g. git's safe.directory) can trip on the
symlinked form. Resolve the real path before handing it to the app.
Stills (PNG)
Keep them boring and crisp: launch, settle generously (a still has no second
chance — a few seconds is fine, you only emit one frame), drive any keystrokes to
reach the state you want, hold briefly, then Screenshot name.png. One scene per
still. If the image looks wrong, increase the settle before reaching for anything
else.
Motion GIFs — direct a short story
A GIF is a screenplay. The discipline that makes them good (and small):
- One story per demo. Make a single point and stop. "The list changes per
view" → show it on two views, done. Resist tacking on extra beats; each one
costs viewer attention and bytes.
- Show contrast, not completeness. Two cases proving a behavior beats six
enumerating it. Full-screen takeovers (help overlays, pickers) are tempting but
dilute the point and balloon the file (see File size).
- Budget read time. Sleep long enough to read each beat: ballpark
~2.5s after opening something with text to read, ~1.2s for a view/screen
switch, ~0.5s between quick keystrokes. Too fast is unreadable; too slow drags
and grows the file.
- Shorter settle than stills. GIFs record from boot, so early "loading"
frames read as natural startup — a ~1.5s lead-in is plenty.
- Type multi-key sequences as one action where the app expects them together
(e.g. a chord); the brief intermediate state on camera often helps by showing
the relationship between keys.
- End on the UI, not a shell prompt. Don't film a trailing quit/
q — let the
recording end on the last meaningful frame. If your pipeline appends a quit for
stills, strip it for GIFs.
File size — the gotcha that ships 18 MB GIFs
This is the lesson that bites hardest, so it gets its own section. VHS writes
full, undeduplicated frames. A 10-second terminal demo where almost nothing
changes between frames still lands at 10-20 MB raw — far too heavy for a web
page or a README. Three levers, in order of impact:
- Optimize losslessly — always. Run
gifsicle -O3 on the output. This is
lossless inter-frame transparency optimization (no --lossy, no colour
quantization): it rewrites only the pixels that change between frames.
Typically a 20-30× reduction with zero visible difference — e.g. a real
demo went 15 MB → 0.4 MB. Use the bundled scripts/optimize_gif.sh (best-
effort: skips with a hint if gifsicle is absent), or wire gifsicle -O3 --batch <file> into your capture pipeline so regenerations stay small without
anyone remembering a manual step. Do this in the pipeline, not by hand —
any "regenerate all captures" command will otherwise re-bloat everything.
- Trim the story. Less duration and fewer full-screen redraws mean
fewer/cheaper frames before optimization even runs. Cutting one unnecessary
full-overlay beat took that same demo 19 MB → 13 MB on its own; gifsicle then
finished the job.
- Shrink dimensions — last resort. Fewer pixels = smaller file, but it costs
legibility and breaks visual consistency across a set of demos. Only reach for
it after the first two.
Rule of thumb: author for the story and timing; let the lossless pass handle
the bytes. If a GIF is still multi-MB after -O3, the recipe is doing too
much — tighten the scene rather than reaching for --lossy and degrading
quality. (If you genuinely need lossy compression for an extreme case, make it an
explicit, opt-in choice the user asked for — never the silent default.)
Scaling up: from one-off tapes to a catalog
A handful of captures can be hand-written tapes checked into assets/ or
docs/. Once a project needs many — every view, every theme, kept in sync as
the UI changes — graduate to a recipe catalog + driver: a typed list of named
scenes, a driver that spins up fixtures, generates the tape, runs VHS, and a sync
step that copies web-ready assets into the docs/site. Read
references/recipe-catalog-pattern.md for the full pattern and a reference
implementation. Signs it's time: people regenerate captures by hand, shots drift
out of sync with the UI, or you're copy-pasting tape boilerplate.
Use cases
- README / docs: a hero GIF plus per-feature stills. Keep GIFs short and
optimized; stills for anything users need to read.
- Marketing site: same captures, synced into the site's asset folder. Animate
the headline workflows; use stills for grids and theme showcases.
- Visual regression: deterministic stills are diff-able (
pixelmatch,
odiff) against a committed baseline. Run as a manual/release CI job, not on
every push — rendering is too slow for the hot path but invaluable at release.