Programming · CLI Tools

Build a Beautiful CLI Tool: Arguments, Colors, and TUI Basics

Turn scripts into tools people actually want to run daily.

Reading time: ~8–12 min
Level: All levels
Updated:

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 respect NO_COLOR)
  • Send errors to stderr, not stdout

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 stdin is a TTY
  • Keep a non-interactive mode for scripts
The “daily use” test

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
A beautiful CLI is still a good Unix citizen

“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.

The biggest color mistake

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 stderr is a TTY
  • Keep output stable in non-interactive mode
  • Provide --quiet and --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: --verbose and --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
Why an output library?

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 to stdout.
  • 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.

Interactive prompts: handle safely
  • 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 --help examples 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 (--output vs --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 --help or show a short example.

Mistake 5 — Interactive prompts in CI

Pipelines hang. Teams lose trust. The tool gets replaced.

  • Fix: prompt only when stdin is 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.
The boring things are the beautiful things

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
  • --help includes 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 respect NO_COLOR)
  • Color is semantic: success/warn/error, not decoration

TUI basics (safe mode)

  • Progress/spinner only when stderr is a TTY
  • Disable TUI automatically for --json and --quiet
  • Prompts only when stdin is 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.

What to do next
  • Pick one internal script and give it a real CLI entrypoint
  • Add --help examples and stable exit codes
  • Add --json mode 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.

1) Which output channel should contain machine-readable results meant for piping?
2) What’s a good default for colored output?
3) When is it safe to show a progress bar / spinner?
4) Which exit code convention is most widely expected?