A good CLI is a tiny product: it has UX, defaults, error messages, and a “contract” with the terminal. This guide shows how to take a one-off script and turn it into a beautiful CLI tool with clean arguments, thoughtful colors, and a few TUI basics (progress, tables, and interactive prompts) without sacrificing “works in pipes/CI” reliability.
Quickstart
Want fast wins? These are the upgrades that make a CLI feel “pro” immediately: predictable arguments, friendly output, and safe defaults when running in CI or piping to another command.
1) Lock in the command shape
Before you code, decide what users will type. A stable command shape prevents future breaking changes.
- Pick a short tool name (one word, no spaces)
- Prefer subcommands for actions:
tool scan,tool report - Make flags consistent:
--verbose,--quiet,--json - Choose one required argument style and stick to it
2) Improve help output (it’s your homepage)
Most users meet your tool via --help. Make it skimmable and example-driven.
- Start with a one-line description (what it does)
- Show 2–4 real examples (copy/paste friendly)
- Group options: Input, Output, Behavior, Debug
- Use clear defaults in help text
3) Add colors responsibly
Color should clarify state (success/warn/error), not decorate every line.
- Color only the important part (labels, numbers, statuses)
- Respect non-TTY output (pipes) and CI environments
- Add
--no-color(and ideally respectNO_COLOR) - Send errors to
stderr, notstdout
4) Add one TUI feature
A small TUI touch makes a tool feel polished: progress, spinner, or a table.
- Progress bar for long operations
- A short summary table at the end
- Interactive prompts only when
stdinis a TTY - Keep a non-interactive mode for scripts
If someone can’t run your tool twice a day without thinking—because output is noisy, flags are confusing, or errors are unclear—it’s still a script.
Overview
This post is about building a CLI that feels good to use and easy to maintain. We’ll cover three pillars: arguments (how users control behavior), colors (how you communicate state), and TUI basics (how to add “app-like” polish without breaking non-interactive use cases).
What you’ll build (conceptually)
A small Python CLI with subcommands, clear help, structured output options (human vs JSON), colored status messages, and a progress bar for longer work. The goal is a pattern you can reuse for internal tools, automation scripts, or open-source utilities.
| Layer | What it adds | Why it matters |
|---|---|---|
| CLI contract | Commands, flags, defaults, exit codes | Stability and predictability for users + scripts |
| Output layer | Readable text, colors, tables, JSON mode | Humans scan faster; machines parse reliably |
| TUI polish | Progress, spinners, prompts (when safe) | Better UX for long tasks without sacrificing pipelines |
“Beautiful” doesn’t mean flashy. It means: quiet by default, loud on error, scriptable, and helpful when interactive.
Core concepts
Before writing code, it helps to internalize a few mental models. They keep you from building a CLI that looks good in a demo but falls apart in real usage (CI, pipes, cron jobs, and other people’s shells).
1) The CLI contract: inputs → outputs → exit codes
A CLI isn’t just “print stuff.” It’s a contract: inputs (arguments, flags, environment) produce outputs (stdout/stderr) and an exit code.
The three channels
- stdout: the primary output (what people pipe)
- stderr: errors, warnings, progress, debug
- exit code: 0 success, non-zero failure (be consistent)
Rule of thumb
If the output is meant to be consumed by another program, it probably belongs on stdout and should be stable. Anything “chatty” goes to stderr so pipelines stay clean.
2) Arguments vs options: don’t mix responsibilities
Positional arguments typically represent required nouns (a file, a path, a URL). Options/flags represent how you do the work (verbose, json, concurrency, retries). When you blur the line, help text gets confusing and users guess wrong.
A practical naming scheme
| Type | Example | When to use |
|---|---|---|
| Positional argument | tool scan ./src |
Required target/input |
| Option with value | --format json |
Choose output/behavior mode |
| Boolean flag | --verbose |
Toggle additional behavior/logging |
3) Color is a signal, not a theme
Good color usage is minimal and semantic: success is green, warning is yellow, error is red. Everything else should remain readable in plain text. Also: colors must degrade gracefully when output is redirected.
Printing ANSI color codes into a file or a pipeline output. If your tool doesn’t detect TTY output (or doesn’t offer
--no-color), users will eventually hate it.
4) “TUI basics” means: better feedback for long-running work
Terminal UI (TUI) is a spectrum. You don’t need a full-screen app to get most of the benefits. Progress bars, spinners, and tables can make a tool feel dramatically better—if they’re only used when the terminal is interactive.
Good TUI defaults
- Show progress only when
stderris a TTY - Keep output stable in non-interactive mode
- Provide
--quietand--verbose - Never block for input unless you know it’s safe
When to go full-screen
Full-screen TUIs are great for dashboards and interactive workflows, but they require more careful state management and accessibility considerations. Start small; scale up only if the workflow truly needs it.
Step-by-step
Let’s build a practical pattern you can reuse. We’ll assume Python (because it ships everywhere), use the standard library for parsing, and add a modern output layer for colors + progress. The same UX ideas apply to any language.
Step 1 — Design the command tree (before code)
Start with the user’s verbs. Most tools naturally fall into subcommands: init, scan, run,
status, report. Keep nouns (targets) as arguments, and keep behavior toggles as flags.
Mini checklist: command design
- 1–3 top-level subcommands (avoid a “kitchen sink”)
- Consistent output modes: human (default) + JSON (optional)
- Consistent logging flags:
--verboseand--quiet - Clear error messages that include next actions
Step 2 — Set up a tiny project skeleton
Even if your tool starts as a single file, packaging it early is worth it: you get a real executable name, a predictable entrypoint,
and an easy path to publishing or installing via pipx.
# Create a minimal project
mkdir -p beautifulcli/src/beautifulcli
cd beautifulcli
python -m venv .venv
source .venv/bin/activate
# Output layer (colors, progress, tables)
pip install rich
# Optional: pipx is great for installing CLI tools globally (recommended for end users)
# pip install pipx
# pipx ensurepath
You can hand-roll ANSI codes and progress bars, but you’ll spend time on edge cases (Windows terminals, width handling, wrapping, TTY detection). A small, focused output layer pays back immediately.
Step 3 — Add a console entrypoint (so users can type your tool name)
Define a script entrypoint in pyproject.toml. This is what turns “python -m …” into “run a command.”
It also makes it easier to install in editable mode during development.
[project]
name = "beautifulcli"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["rich>=13.0.0"]
[project.scripts]
beautiful = "beautifulcli.cli:main"
Put your entry function at src/beautifulcli/cli.py. From here on, you’re building a real tool: stable name, stable API.
Step 4 — Implement arguments + colors + progress (a reusable pattern)
The key design choices:
(1) parse args with subcommands,
(2) send human-friendly messages to stderr,
(3) keep machine output clean on stdout when --json is used,
(4) enable colors/progress only when appropriate.
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from rich.console import Console
from rich.table import Table
from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeElapsedColumn
@dataclass(frozen=True)
class Settings:
verbose: bool
quiet: bool
json_output: bool
color: bool
def _supports_color(force_color: bool, force_no_color: bool) -> bool:
"""
Decide whether to use color output.
Priority:
1) explicit flags
2) NO_COLOR env convention
3) TTY detection
"""
if force_no_color:
return False
if force_color:
return True
if os.environ.get("NO_COLOR") is not None:
return False
return sys.stderr.isatty()
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="beautiful",
description="A tiny example of a beautiful CLI: clean args, responsible colors, and TUI basics.",
)
# Global flags (apply to all subcommands)
p.add_argument("--verbose", action="store_true", help="Show debug details on stderr.")
p.add_argument("--quiet", action="store_true", help="Only print errors.")
p.add_argument("--json", dest="json_output", action="store_true", help="Print machine-readable output to stdout.")
p.add_argument("--color", action="store_true", help="Force color output (overrides NO_COLOR).")
p.add_argument("--no-color", action="store_true", help="Disable color output.")
sub = p.add_subparsers(dest="cmd", required=True)
scan = sub.add_parser("scan", help="Scan a directory and summarize file types.")
scan.add_argument("path", type=Path, help="Directory to scan.")
scan.add_argument("--max-files", type=int, default=5000, help="Safety limit for number of files to inspect.")
report = sub.add_parser("report", help="Print a demo report (table) from a JSON file.")
report.add_argument("json_path", type=Path, help="Path to JSON produced by 'beautiful scan --json'.")
return p
def _error(console: Console, msg: str, exit_code: int = 2) -> int:
console.print(f"[bold red]error:[/bold red] {msg}", highlight=False)
return exit_code
def _scan_dir(path: Path, max_files: int, console: Console, settings: Settings) -> dict[str, Any]:
if not path.exists():
raise FileNotFoundError(f"Path does not exist: {path}")
if not path.is_dir():
raise NotADirectoryError(f"Not a directory: {path}")
counts: dict[str, int] = {}
total = 0
# TUI basics: show progress only for interactive stderr and when not quiet/json
show_progress = sys.stderr.isatty() and (not settings.quiet) and (not settings.json_output)
progress = Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TextColumn("{task.completed} files"),
TimeElapsedColumn(),
console=console,
transient=True,
disable=not show_progress,
)
with progress:
task = progress.add_task("Scanning…", total=None)
for p in path.rglob("*"):
if p.is_dir():
continue
total += 1
if total > max_files:
break
ext = p.suffix.lower() or "(no_ext)"
counts[ext] = counts.get(ext, 0) + 1
# Small sleep so the progress demo is visible on fast disks (remove in real tools)
time.sleep(0.0004)
progress.update(task, completed=total)
truncated = total > max_files
return {"path": str(path), "total_files": min(total, max_files), "truncated": truncated, "by_ext": counts}
def _print_scan_human(result: dict[str, Any], console: Console, settings: Settings) -> None:
if settings.quiet:
return
status = "TRUNCATED" if result.get("truncated") else "OK"
badge = "[yellow]TRUNCATED[/yellow]" if status == "TRUNCATED" else "[green]OK[/green]"
console.print(f"Scan: {result['path']} Status: {badge}")
table = Table(title="File types", show_lines=False)
table.add_column("Extension", style="cyan", no_wrap=True)
table.add_column("Count", justify="right", style="magenta")
items = sorted(result["by_ext"].items(), key=lambda kv: kv[1], reverse=True)[:12]
for ext, count in items:
table.add_row(ext, str(count))
console.print(table)
console.print(f"Total files inspected: [bold]{result['total_files']}[/bold]")
def _print_scan_json(result: dict[str, Any]) -> None:
# Machine output should be stable and go to stdout
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
def _report_from_json(json_path: Path, console: Console, settings: Settings) -> int:
try:
data = json.loads(json_path.read_text(encoding="utf-8"))
except Exception as e:
return _error(console, f"Could not read JSON: {e}", exit_code=2)
if not isinstance(data, dict) or "by_ext" not in data:
return _error(console, "JSON doesn't look like output from 'beautiful scan --json'.", exit_code=2)
by_ext = data.get("by_ext", {})
if not isinstance(by_ext, dict):
return _error(console, "Invalid JSON structure: by_ext must be an object.", exit_code=2)
table = Table(title="Report (from JSON)")
table.add_column("Extension", style="cyan", no_wrap=True)
table.add_column("Count", justify="right", style="magenta")
for ext, count in sorted(by_ext.items(), key=lambda kv: kv[1], reverse=True)[:20]:
table.add_row(str(ext), str(count))
if not settings.quiet:
console.print(table)
return 0
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
settings = Settings(
verbose=bool(args.verbose),
quiet=bool(args.quiet),
json_output=bool(args.json_output),
color=_supports_color(force_color=bool(args.color), force_no_color=bool(args.no_color)),
)
console = Console(
stderr=True,
color_system="auto" if settings.color else None,
no_color=not settings.color,
force_terminal=settings.color and sys.stderr.isatty(),
)
if settings.verbose and not settings.quiet:
console.print("[dim]debug:[/dim] starting CLI", highlight=False)
try:
if args.cmd == "scan":
result = _scan_dir(args.path, args.max_files, console, settings)
if settings.json_output:
_print_scan_json(result)
else:
_print_scan_human(result, console, settings)
return 0
if args.cmd == "report":
return _report_from_json(args.json_path, console, settings)
return _error(console, "Unknown command.", exit_code=2)
except KeyboardInterrupt:
return _error(console, "Interrupted (Ctrl+C).", exit_code=130)
except Exception as e:
if settings.verbose:
console.print_exception(show_locals=False)
return _error(console, str(e), exit_code=1)
if __name__ == "__main__":
raise SystemExit(main())
How to use the pattern
- Human mode (default): prints a table + friendly status to
stderr. - Machine mode (
--json): prints stable JSON tostdout. - Colors: enabled only when appropriate; can be forced or disabled.
- TUI basics: progress runs only when interactive and not in JSON/quiet mode.
Step 5 — Make error handling predictable
Predictable errors are a feature. Users should know what happened, what to do next, and whether a script should continue. Your most important choices are: exit codes and stderr vs stdout.
Recommended exit codes (simple)
- 0: success
- 1: general failure (unexpected exception)
- 2: usage/validation error (bad args, bad input)
- 130: interrupted by user (Ctrl+C)
Validation order
Validate inputs early (path exists, file readable, JSON shape ok) and fail fast with a short message.
If you support --verbose, show a traceback only when requested.
Step 6 — Add polish without breaking scripts
The fastest way to annoy power users is to introduce interactive prompts in pipelines. If you add prompts, gate them:
only prompt when stdin is a TTY, and always provide a non-interactive flag or default.
- Never prompt if
not sys.stdin.isatty() - Provide
--yes(assume yes) or--non-interactive - Time out or fail fast in CI contexts
Step 7 — Ship: install, version, and document
Shipping a CLI tool is mostly about trust: stable behavior over time. That comes from versioning, changelogs, and examples users can copy into their README or shell scripts.
Shipping checklist
- Pin a minimum Python version and document it
- Write
--helpexamples for each subcommand - Decide backwards compatibility rules (what counts as breaking?)
- Keep output stable in JSON mode
- Test in a pipe:
tool --json | jq(or similar)
Common mistakes
These are the failure modes that make CLI tools feel “rough.” The fixes are usually small, but they have outsized impact.
Mistake 1 — Printing everything to stdout
If progress logs go to stdout, pipes become unusable and automation becomes fragile.
- Fix: send user-facing logs and progress to
stderr. - Fix: keep machine output (JSON/CSV) clean on
stdout.
Mistake 2 — Inconsistent flags across subcommands
Users build muscle memory. Breaking it increases mistakes and support requests.
- Fix: define global flags (
--verbose,--quiet,--json) once. - Fix: keep names consistent (
--outputvs--out: pick one).
Mistake 3 — Color everywhere (and in logs/files)
ANSI codes in redirected output is the classic “why is my file full of weird characters?” bug.
- Fix: disable color when not a TTY; add
--no-color. - Fix: color only labels/status, not entire paragraphs.
Mistake 4 — Hiding the “next action” in errors
“Failed” is not actionable. Users need a hint: which input, which flag, what format?
- Fix: include the failing value and expected format.
- Fix: point to
--helpor show a short example.
Mistake 5 — Interactive prompts in CI
Pipelines hang. Teams lose trust. The tool gets replaced.
- Fix: prompt only when
stdinis a TTY. - Fix: add
--yes/--non-interactive.
Mistake 6 — No tests for the CLI boundary
The CLI boundary is where regressions happen (help text, exit codes, stdout/stderr).
- Fix: test with subprocess and assert exit codes and output channels.
- Fix: keep JSON output stable and versioned.
Clean help text, stable output, and correct exit codes don’t look exciting—but they’re what makes a CLI tool feel dependable enough to adopt across a team.
FAQ
Should I use argparse, Click, or Typer?
If you want zero dependencies and maximum portability, start with argparse. If you want a faster developer experience (type hints, autocompletion patterns, nicer ergonomics), consider Click/Typer. The UX principles in this post apply either way: stable commands, responsible output, and safe interactivity.
How do I handle colors in CI and when piping output?
Default to no color when output isn’t a TTY, and provide a --no-color flag.
Many tools also respect NO_COLOR to disable color globally. If you need color in CI logs, provide
--color to force it, but don’t make it the default.
What’s the best practice for stdout vs stderr?
Put primary results on stdout (especially in --json mode), and put status/progress/errors
on stderr. This keeps pipelines clean and makes your tool composable with other commands.
How should I design subcommands and flags?
Design subcommands around verbs (actions) and keep flags consistent. A good heuristic:
global flags change behavior across the tool (--verbose, --quiet, --json),
while subcommand flags are specific to that action (scan --max-files).
How do I add TUI features without breaking scripts?
Gate TUI features behind TTY checks and keep a non-interactive mode. Show progress on stderr only when interactive,
and disable it automatically for --json or --quiet. If you prompt, do it only when stdin is a TTY
and offer --yes or --non-interactive.
How do I package and distribute a Python CLI?
Use a pyproject.toml entrypoint ([project.scripts]) so users can run your tool by name.
For end users, recommend installation via pipx (isolated, global command). For internal teams, publish to a private index
or distribute as a pinned dependency in your repo.
Cheatsheet
CLI UX checklist
- Command tree: verbs as subcommands, nouns as args
--helpincludes examples and sensible defaults- Global flags:
--verbose,--quiet,--json - Errors say what happened + what to do next
- Stable exit codes (0 success, non-zero failure)
Output & color rules
- stdout = primary output (pipeable)
- stderr = errors, warnings, progress, debug
- Disable color when not a TTY
- Add
--no-color(and optionally respectNO_COLOR) - Color is semantic: success/warn/error, not decoration
TUI basics (safe mode)
- Progress/spinner only when
stderris a TTY - Disable TUI automatically for
--jsonand--quiet - Prompts only when
stdinis a TTY - Always provide a non-interactive alternative
- Keep output stable across versions in machine mode
Maintenance checklist
- Version your CLI and document breaking changes
- Add smoke tests: exit codes + stdout/stderr
- Keep JSON schema stable or version it
- Document common workflows (copy/paste snippets)
- Test in pipes and in CI
Wrap-up
Turning a script into a beautiful CLI tool is mostly about user trust: predictable arguments, readable output, and graceful behavior in both interactive terminals and non-interactive environments. If you implement the patterns above—clean command design, responsible colors, TUI feedback that doesn’t break pipes—you’ll have a tool people actually want to run daily.
- Pick one internal script and give it a real CLI entrypoint
- Add
--helpexamples and stable exit codes - Add
--jsonmode so other tools can consume it - Ship v0.1.0 and iterate based on real usage (not guesses)
Quiz
Quick self-check (demo). This quiz is auto-generated for programming / cli / tools.