Design and evaluate command-line tools for human users: naming and grammar, interactive prompts, colour and progress output, error messages, and a 0-21 usability rubric
72
91%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
How to render CLI output that guides the human eye to what matters — using tables, color, icons, and TTY-aware formatting. Output is the CLI's primary communication channel with the user; every design choice either clarifies or clutters.
The same CLI serves two audiences from one binary by checking whether stdout is a terminal:
stdout is a TTY → Human mode: tables, color, icons, alignment
stdout is piped → Machine mode: plain text, one item per line, no ANSI
--json flag → Force JSON envelope regardless of TTY
--no-color flag → Human formatting without ANSI escape codesNode.js:
import { isatty } from 'node:tty';
const humanMode = isatty(1) && !process.env.NO_COLOR && !opts.noColor;Python:
import sys
human_mode = sys.stdout.isatty() and not os.environ.get('NO_COLOR') and not args.no_colorGo:
import "golang.org/x/term"
humanMode := term.IsTerminal(int(os.Stdout.Fd())) && os.Getenv("NO_COLOR") == "" && !opts.NoColorRust:
use atty::is;
let human_mode = is(atty::Stream::Stdout) && std::env::var("NO_COLOR").is_err() && !opts.no_color;
// Or use `is-terminal` crate (atty successor): stdout().is_terminal()| Concern | Human mode (TTY) | Machine mode (piped) |
|---|---|---|
| Color | Semantic ANSI codes | None — strip all escape sequences |
| Tables | Aligned columns with headers | TSV or one-item-per-line |
| Icons | Unicode symbols (checkmark, cross, arrow) | Text labels only |
| Progress | Spinner/bar on stderr | Silent or single status line |
| Width | Adapt to terminal width (process.stdout.columns) | No width assumption |
Use color to reinforce meaning, never as the sole carrier of meaning. Every colored element must also have a text label, icon, or structural distinction.
| Meaning | Color | ANSI | Example |
|---|---|---|---|
| Success | Green | \x1b[32m | ✓ Deployed to production |
| Error | Red | \x1b[31m | ✗ Build failed: missing dependency |
| Warning | Yellow | \x1b[33m | ⚠ Config file not found, using defaults |
| Info | Cyan | \x1b[36m | ℹ Using region us-east-1 |
| Muted/secondary | Dim/gray | \x1b[2m | Timestamps, IDs, metadata |
| Emphasis | Bold | \x1b[1m | Command names, resource names |
| User input echo | Magenta | \x1b[35m | Values the user typed |
NAME STATUS REPLICAS AGE
web-server Running 3/3 2d
api-gateway Pending 1/3 5m
worker Failed 0/3 1h… rather than wrapping to the next line: my-very-long-resource-na…--border for users who prefer them.Don't print nothing. Don't print an empty table:
# Bad
NAME STATUS REPLICAS
# Good
No pods found. Create one with: mycli pod create --name my-podAn empty state is a teaching moment — tell the user what to do next.
Use Unicode symbols sparingly to reinforce status:
| Symbol | Meaning | Usage |
|---|---|---|
✓ (U+2713) | Success/complete | ✓ Tests passed |
✗ (U+2717) | Failure/error | ✗ Build failed |
⚠ (U+26A0) | Warning | ⚠ Deprecated flag |
ℹ (U+2139) | Information | ℹ Using default config |
→ (U+2192) | Arrow/flow | → Deploying to us-east-1 |
● (U+25CF) | Bullet/status dot | ● Running ○ Stopped |
… (U+2026) | Truncation | my-long-name… |
✓ Passed not just ✓. Icons supplement; they don't replace.TERM=dumb or a legacy terminal, fall back to ASCII: [OK], [FAIL], [WARN], [INFO].Respect the no-color.org convention:
--no-color flag → disableNO_COLOR environment variable (any value, including empty string) → disableFORCE_COLOR environment variable → enable (overrides NO_COLOR if both set — controversial, some tools support it)TERM=dumb → disabledef should_color(args):
if args.no_color:
return False
if 'NO_COLOR' in os.environ:
return False
if os.environ.get('TERM') == 'dumb':
return False
return sys.stdout.isatty()Always test output with NO_COLOR=1 mycli list | cat to verify ANSI stripping works. Search the output for \x1b — any match is a bug.
When writing to a non-TTY, strip all ANSI escape sequences before writing. Don't rely on the terminal to ignore them — downstream tools (grep, wc, awk) see them as characters.
// Node.js
function stripAnsi(str) {
return str.replace(/\x1b\[[0-9;]*m/g, '');
}# Python
import re
def strip_ansi(text):
return re.sub(r'\x1b\[[0-9;]*m', '', text)Libraries that handle this automatically:
chalk respects NO_COLOR and non-TTY. strip-ansi for manual stripping.rich respects NO_COLOR. colorama + strip_ansi.fatih/color respects NO_COLOR and non-TTY.colored respects NO_COLOR. console crate for auto-detection.| Content | Destination | Why |
|---|---|---|
| Data (tables, results, JSON) | stdout | Consumers pipe and redirect stdout |
| Errors | stderr | Errors must appear even when stdout is redirected |
| Warnings | stderr | Same reason as errors |
| Progress (spinners, bars) | stderr | Progress indicators pollute piped data |
| Prompts | stderr (or /dev/tty) | Prompts must appear even when stdout is piped |
| Debug/verbose output | stderr | Verbose diagnostics are for the operator, not the consumer |
Rule: If in doubt, stderr. Only data the user explicitly asked for goes to stdout.
Detect terminal width and adapt output:
import shutil
width = shutil.get_terminal_size().columns # Default 80 if not a TTYconst width = process.stdout.columns || 80;| Width | Strategy |
|---|---|
| < 60 | Drop all but essential columns. Stack key-value pairs vertically. |
| 60-100 | Show primary columns with truncation. |
| 100-160 | Show all columns comfortably. |
| > 160 | Add spacing between columns. Don't stretch to fill ultra-wide terminals. |
Cap maximum output width at ~160 characters even on very wide terminals. Text that spans 300 characters is harder to read than text at 120.