| name | tmux-cli-driver |
| description | Use when an interactive command-line tool needs to be tested as a real user would drive it — a CLI that only behaves correctly with a real TTY (prompts, password masking, spinners, full-screen TUIs, paging), reproducing a "the prompt hangs / the wizard skips a step" bug, or asserting on what an internal CLI actually prints to the terminal rather than what a piped/non-interactive invocation does. Reach for this whenever piping stdin "works" but the interactive path is what's broken. |
tmux CLI Driver
Overview
Some CLIs only misbehave when they think they're talking to a person. They check
isatty(), mask passwords, draw spinners, repaint a full-screen UI, or page
output — and a plain echo answer | mycli either takes a different code path or
hangs forever. Capturing stdout with a pipe tells you nothing about the
interactive experience, because the pipe is the thing that changed the
behavior.
This skill drives an interactive CLI inside a real TTY by running it in a
tmux pane: it sends keystrokes, captures the rendered pane contents, waits
for the prompt to actually settle, strips ANSI escape codes, and asserts on the
visible text. The CLI runs exactly as it would for a human — same TTY checks,
same prompts, same redraw — and the test reads what's on screen.
The discipline that makes it reliable: wait for a concrete on-screen condition
before sending the next key or asserting — never a fixed sleep. A spinner or
a slow prompt makes fixed sleeps either flaky or needlessly slow; settling on the
prompt text is deterministic.
When to Use
Reach for this when:
- A CLI behaves differently interactively than when piped — it checks for a TTY,
masks input, or only shows a wizard/prompt when attached to a terminal.
- You're reproducing "the prompt hangs", "the wizard skipped step 2", "the
spinner never stops", or "it printed the menu twice" — all TTY-rendering bugs a
pipe can't reproduce.
- You need to assert on what an internal CLI displays (a confirmation prompt, a
progress line, a colored warning) rather than just its exit code or piped
stdout.
- You're adding a smoke test for an interactive internal tool to CI and need it to
exercise the real terminal path.
Do NOT use this when:
- The CLI is fully non-interactive (flags only, no prompts). Just run it and check
exit code + stdout; tmux is pure overhead.
- You can pass every answer via flags or a
--yes mode and you don't care about
the rendered output — prefer that; it's faster and less flaky.
- You're testing a long-running daemon's logs — tail the log file, don't scrape a
pane.
tmux isn't available on the runner and can't be installed. This depends on it.
Running it
cd .claude/skills/tmux-cli-driver
scripts/tmux_driver.sh \
--cmd "mycli init" \
--expect "Project name:" --send "demo-app" \
--expect "Use TypeScript?" --send "y" \
--expect "Created project demo-app" \
--capture artifacts/init.txt
Each --expect STR waits (with a deadline) until STR appears in the live pane;
each following --send STR types it and presses Enter. A trailing --expect
with no --send is the final assertion. The captured, ANSI-stripped pane is
written to --capture (default artifacts/<run>.txt), and the script exits
non-zero if any --expect times out — so it drops into CI.
Run scripts/tmux_driver.sh --help for the full flag list (send-raw keys like
Down/Enter/C-c, custom timeouts, keeping the pane open for debugging).
How the driver works
- Spawn in a detached tmux session with a fixed pane size (so wrapping is
deterministic) running the target command.
- Wait for a prompt to settle by polling
tmux capture-pane until the
expected substring appears and the pane stops changing between two polls —
that "no longer changing" check is what replaces a sleep and avoids sending
keys into a half-drawn prompt.
- Send keys with
tmux send-keys, using literal mode for text and named
keys (Enter, Down, C-c) where needed.
- Capture and normalize the pane with
capture-pane -p, then strip ANSI/CSI
escape sequences before matching — colored or cursor-moving output otherwise
defeats a plain substring assert.
- Assert each
--expect against the normalized capture; on timeout, dump the
last pane contents so the failure shows what was actually on screen.
- Tear the pane down in a trap so a hung CLI or a failed assert never leaves
an orphaned tmux session behind.
Gotchas
- Don't
sleep — wait for the prompt to settle. Output is buffered and
prompts render at variable speed; a fixed sleep is flaky (too short) or slow
(too long). Poll capture-pane until the expected text is present and two
consecutive captures are identical (the pane stopped redrawing), then act.
- Strip ANSI codes before asserting. Interactive CLIs emit color and
cursor-movement escapes (
\e[…m, \e[…H). A raw substring match against
Created project fails when the real bytes are \e[32mCreated\e[0m project.
Normalize the capture (the script runs it through a sed ANSI filter) before
every comparison.
- Output buffering hides the prompt. Many programs only flush a prompt that
ends without a newline once they detect a TTY — which is exactly why we use
tmux. If a prompt still never appears, the CLI may be buffering stdout itself;
set the pane's
TERM, and if needed run the CLI under stdbuf -oL / a PTY so
line-buffered output reaches the pane.
- Pane size affects wrapping and assertions. A narrow pane wraps a long
prompt across lines and your substring spans the wrap. Set an explicit, wide
pane size when creating the session and keep it constant so captures are
reproducible across machines.
- Always tear the session down. A CLI that hangs on a prompt you didn't answer
leaves a live tmux session pinning the process. Kill the session in an
EXIT
trap (and on timeout) so reruns start clean and CI runners don't leak sessions.
send-keys literal vs. interpreted. tmux send-keys "y" sends the text;
tmux send-keys Enter sends the Return key. Mixing them up means you either
type the word "Enter" or never submit the answer. Use -l for literal text and
bare key names for control keys.
- Match a unique, stable substring. Asserting on a whole line including a
timestamp, a spinner frame, or a path that varies per machine makes the test
flaky. Pick the shortest substring that uniquely identifies the prompt/result.
Files
scripts/tmux_driver.sh — POSIX/bash driver. Spawns the target CLI in a
fixed-size detached tmux pane, runs an --expect / --send sequence (waiting
for each prompt to settle by polling capture-pane until it stops changing),
strips ANSI codes before asserting, writes the normalized capture to a file, and
kills the session in an EXIT trap. Exits non-zero on any timed-out
expectation. Referenced by Running it and How the driver works above.