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", "ch...
Scanned 9/3/2026
Install to Claude Code
npx -y skills add gfargo/skills --skill vhs-cli-demos --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Vhs Cli Demos?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/gfargo-vhs-cli-demos)More formats (shields.io, HTML) on the badges page.
---
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](https://github.com/charmbracelet/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:
1. **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.
2. **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.)
3. **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
```bash
brew install vhs # also available via Go, Nix, apt, scoop, docker
# VHS needs ffmpeg (and ttyd) for video/gif encoding — brew pulls them in.
brew install gifsicle # for the lossless GIF optimization step (below)
```
Verify with `vhs --version`. If `vhs new demo.tape` errors about ttyd/ffmpeg,
install those explicitly (`brew install ttyd ffmpeg`).
## The core workflow
1. **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.
2. **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.
3. **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.
4. **Render**: `vhs demo.tape`. Inspect the output. Iterate on timing.
5. **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:
1. **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.
2. **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.
3. **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.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!