Skip to content
C++
Domain Track
Difficulty 2/5

CLI Tools in C++

C++ for command-line tools — argument parsing, terminal output, progress bars, readline, piped I/O, exit codes, and signal handling.

TL;DR

Building CLI tools in C++ means: parse arguments (use a library), detect terminal capabilities, handle signals gracefully, and follow Unix conventions (exit codes, stdin/stdout/stderr, pipes).


Argument Parsing

Manual (Simple Tools)

cpp
#include <span>
#include <string_view>

struct Options {
    std::string input;
    std::string output;
    bool verbose  = false;
    int  jobs     = 1;
};

Options parse_args(int argc, char* argv[]) {
    Options opts;
    auto args = std::span{argv + 1, static_cast<size_t>(argc - 1)};

    for (size_t i = 0; i < args.size(); ++i) {
        std::string_view arg = args[i];
        if (arg == "-v" || arg == "--verbose") {
            opts.verbose = true;
        } else if (arg == "-j" || arg == "--jobs") {
            if (++i >= args.size()) throw std::runtime_error("--jobs requires a value");
            std::from_chars(args[i], args[i] + std::strlen(args[i]), opts.jobs);
        } else if (arg == "-o" || arg == "--output") {
            if (++i >= args.size()) throw std::runtime_error("--output requires a value");
            opts.output = args[i];
        } else if (arg.starts_with('-')) {
            throw std::runtime_error("unknown option: " + std::string{arg});
        } else {
            opts.input = std::string{arg};
        }
    }
    return opts;
}
cpp
// With CLI11 (header-only):
#include <CLI/CLI.hpp>

int main(int argc, char* argv[]) {
    CLI::App app{"My Tool", "mytool"};

    std::string input, output;
    bool verbose = false;
    int jobs = 1;

    app.add_option("input",  input,  "Input file")->required();
    app.add_option("-o,--output", output, "Output file")->required();
    app.add_flag("-v,--verbose", verbose, "Verbose output");
    app.add_option("-j,--jobs",  jobs,   "Parallel jobs")->default_val(1);

    CLI11_PARSE(app, argc, argv);  // handles --help, errors, etc.

    if (verbose) std::println("input: {}", input);
    return 0;
}

Terminal Detection and ANSI Colors

cpp
#include <unistd.h>

bool is_tty() { return isatty(STDOUT_FILENO); }

namespace color {
    const char* reset   = "\033[0m";
    const char* red     = "\033[31m";
    const char* green   = "\033[32m";
    const char* yellow  = "\033[33m";
    const char* blue    = "\033[34m";
    const char* bold    = "\033[1m";
    const char* dim     = "\033[2m";
}

void print_status(bool ok, std::string_view msg) {
    if (is_tty()) {
        // Colorized output for terminal
        std::println("{}[{}]{} {}",
            ok ? color::green : color::red,
            ok ? "OK" : "FAIL",
            color::reset, msg);
    } else {
        // Plain output for pipes/files
        std::println("[{}] {}", ok ? "OK" : "FAIL", msg);
    }
}

Progress Bar

cpp
class ProgressBar {
    size_t total_;
    size_t current_ = 0;
    int    width_   = 40;

public:
    explicit ProgressBar(size_t total) : total_{total} {}

    void update(size_t n = 1) {
        current_ = std::min(current_ + n, total_);
        render();
    }

    void render() const {
        if (!isatty(STDOUT_FILENO)) return;

        double pct = total_ > 0 ? static_cast<double>(current_) / total_ : 0.0;
        int filled = static_cast<int>(pct * width_);

        std::print("\r[");
        for (int i = 0; i < width_; ++i)
            std::print("{}", i < filled ? '#' : '.');
        std::print("] {:.0f}% ({}/{})", pct * 100.0, current_, total_);
        std::cout.flush();

        if (current_ == total_) std::println("");
    }
};

ProgressBar pb{100};
for (int i = 0; i < 100; ++i) {
    process_item(i);
    pb.update();
}

Piped I/O

cpp
// Detect if stdin has data (piped)
bool has_piped_input() { return !isatty(STDIN_FILENO); }

// Process lines from stdin
void process_stdin() {
    for (std::string line; std::getline(std::cin, line); ) {
        process_line(line);
    }
}

// Unix tool pattern: file args or stdin
int main(int argc, char* argv[]) {
    if (argc > 1) {
        // Process files
        for (int i = 1; i < argc; ++i) {
            std::ifstream f{argv[i]};
            if (!f) { std::println(stderr, "cannot open: {}", argv[i]); continue; }
            for (std::string line; std::getline(f, line); )
                process_line(line);
        }
    } else {
        // Fall back to stdin
        process_stdin();
    }
}

Exit Codes (Unix Conventions)

cpp
// Standard exit codes
#include <cstdlib>

// 0: success
return EXIT_SUCCESS;   // or return 0;

// 1: general error
return EXIT_FAILURE;   // or return 1;

// 2: wrong usage (bad arguments)
if (argc < 2) {
    std::println(stderr, "usage: {} <file>", argv[0]);
    return 2;
}

// 127: command not found (used by shells)
// 128+N: terminated by signal N
// exit() vs return from main: both call atexit() handlers

// Quick_exit (C++11): no destructors, no atexit
std::quick_exit(1);   // emergency exit

Signal Handling

cpp
#include <signal.h>
#include <atomic>

std::atomic<bool> g_interrupted{false};

extern "C" void on_signal(int) {
    g_interrupted.store(true, std::memory_order_relaxed);
}

void setup_signals() {
    struct sigaction sa{};
    sa.sa_handler = on_signal;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;
    sigaction(SIGINT,  &sa, nullptr);  // Ctrl+C
    sigaction(SIGTERM, &sa, nullptr);  // kill
}

int main() {
    setup_signals();

    for (size_t i = 0; !g_interrupted.load(); ++i) {
        process_item(i);
    }

    if (g_interrupted) std::println(stderr, "\ninterrupted");
    return g_interrupted ? 130 : 0;  // 128 + SIGINT(2) = 130 convention
}

Configuration Files

cpp
// Read a simple KEY=VALUE config file
std::unordered_map<std::string, std::string> read_config(const std::filesystem::path& p) {
    std::unordered_map<std::string, std::string> cfg;
    std::ifstream f{p};
    for (std::string line; std::getline(f, line); ) {
        // Strip comments and blank lines
        if (auto pos = line.find('#'); pos != std::string::npos)
            line.resize(pos);
        if (line.find_first_not_of(" \t\r\n") == std::string::npos)
            continue;

        // Parse KEY=VALUE
        auto eq = line.find('=');
        if (eq == std::string::npos) continue;
        auto key = line.substr(0, eq);
        auto val = line.substr(eq + 1);
        cfg[key] = val;
    }
    return cfg;
}

// Priority: defaults → config file → environment → CLI args
auto config = read_config(find_config_path());
if (auto* env = std::getenv("MY_OUTPUT")) config["output"] = env;
// then override from CLI args...

Option Parsing Libraries — Comparison

Choosing an argument-parsing library is one of the first decisions for any non-trivial CLI tool. The three most popular header-friendly options differ mainly in API ergonomics and feature scope.

cpp
// --- CLI11 ---
// cmake: FetchContent or vcpkg install cli11
#include <CLI/CLI.hpp>

CLI::App app{"Compress files", "zp"};
std::vector<std::string> inputs;
std::string output;
int level = 6;
bool force = false;

app.add_option("files", inputs, "Input files")->required()->check(CLI::ExistingFile);
app.add_option("-o,--output", output, "Output archive")->required();
app.add_option("-l,--level",  level,  "Compression level 1-9")->check(CLI::Range(1,9));
app.add_flag  ("-f,--force",  force,  "Overwrite existing output");
app.require_subcommand(0);           // 0 = optional subcommands

CLI11_PARSE(app, argc, argv);

// --- cxxopts ---
#include <cxxopts.hpp>

cxxopts::Options opts("zp", "Compress files");
opts.add_options()
    ("o,output", "Output archive",     cxxopts::value<std::string>())
    ("l,level",  "Compression level",  cxxopts::value<int>()->default_value("6"))
    ("f,force",  "Overwrite output")
    ("h,help",   "Print usage");
auto result = opts.parse(argc, argv);
if (result.count("help")) { std::cout << opts.help(); return 0; }

// --- argparse (C++17, std-like API) ---
#include <argparse/argparse.hpp>

argparse::ArgumentParser prog("zp");
prog.add_argument("files").nargs(argparse::nargs_pattern::at_least_one);
prog.add_argument("-o","--output").required();
prog.add_argument("-l","--level").default_value(6).scan<'i', int>();
prog.add_argument("-f","--force").flag();
prog.parse_args(argc, argv);
FeatureCLI11cxxoptsargparse
Subcommandsyesnoyes
Validators/checksrich built-insmanualmanual
Shell completionvia scriptsnono
Config file integrationyes (.ini)nono
Header-onlyyesyesyes
C++ standard requiredC++11C++11C++17
Approximate LoC7 0001 2003 500

CLI11 is the default recommendation for anything with subcommands or validation needs. cxxopts is the smallest drop-in for simple flags. argparse mirrors Python's API most closely.


ANSI Terminal Colors and Formatting

ANSI escape sequences are the portable way to add color and cursor control, but must be suppressed when output is redirected. Three conditions disable color: non-TTY stdout, NO_COLOR=1 env var (spec: no-color.org), and TERM=dumb.

cpp
#include <cstdlib>
#include <unistd.h>

bool color_enabled() {
    // Respect NO_COLOR spec (https://no-color.org)
    if (std::getenv("NO_COLOR")) return false;
    // Dumb terminal
    const char* term = std::getenv("TERM");
    if (term && std::string_view{term} == "dumb") return false;
    // Not a TTY (redirected to file or pipe)
    return isatty(STDOUT_FILENO) != 0;
}

// Escape code reference
namespace ansi {
    // Styles
    constexpr auto reset     = "\033[0m";
    constexpr auto bold      = "\033[1m";
    constexpr auto dim       = "\033[2m";
    constexpr auto italic    = "\033[3m";
    constexpr auto underline = "\033[4m";
    // Foreground colors (standard)
    constexpr auto black   = "\033[30m";
    constexpr auto red     = "\033[31m";
    constexpr auto green   = "\033[32m";
    constexpr auto yellow  = "\033[33m";
    constexpr auto blue    = "\033[34m";
    constexpr auto magenta = "\033[35m";
    constexpr auto cyan    = "\033[36m";
    constexpr auto white   = "\033[37m";
    // Bright foreground (non-standard but universal)
    constexpr auto bright_red   = "\033[91m";
    constexpr auto bright_green = "\033[92m";
    // 256-color: \033[38;5;{n}m  (n = 0-255)
    // True color: \033[38;2;{r};{g};{b}m
    // Cursor: move up N lines = "\033[{N}A", clear line = "\033[2K"
}

struct Colorizer {
    bool enabled;
    std::string operator()(std::string_view code, std::string_view text) const {
        if (!enabled) return std::string{text};
        return std::string{code} + std::string{text} + ansi::reset;
    }
};

// Usage
Colorizer C{color_enabled()};
std::println("{}: {}", C(ansi::bold, "error"), C(ansi::red, "file not found"));

In CI pipelines (GitHub Actions, GitLab CI) isatty returns false, so colors are automatically suppressed. If you need color in CI logs, check for GITHUB_ACTIONS or CI env vars explicitly.


Progress Bars and Spinners

Rate-limiting progress bar updates is essential — redrawing on every iteration of a tight loop wastes more time than the work being tracked. Throttle to 20-30 Hz maximum.

cpp
#include <chrono>
#include <array>

class Spinner {
    static constexpr std::array frames = {"⠋","⠙","⠹","⠸","⠼","⠴","⠦","⠧","⠇","⠏"};
    size_t frame_ = 0;
    std::string label_;
public:
    explicit Spinner(std::string label) : label_{std::move(label)} {}
    void tick() {
        if (!isatty(STDOUT_FILENO)) return;
        std::print("\r{} {}", frames[frame_++ % frames.size()], label_);
        std::cout.flush();
    }
    void done(bool ok = true) {
        if (!isatty(STDOUT_FILENO)) { std::println("{}", label_); return; }
        std::println("\r{} {}", ok ? "✓" : "✗", label_);
    }
};

class ThrottledProgress {
    size_t total_, current_ = 0, width_ = 40;
    using Clock = std::chrono::steady_clock;
    Clock::time_point last_render_ = Clock::time_point::min();
    static constexpr auto kMinInterval = std::chrono::milliseconds{33}; // ~30 Hz

public:
    explicit ThrottledProgress(size_t total) : total_{total} {}

    void update(size_t n = 1) {
        current_ = std::min(current_ + n, total_);
        auto now = Clock::now();
        if (now - last_render_ < kMinInterval && current_ < total_) return;
        last_render_ = now;
        render();
    }

    void render() const {
        if (!isatty(STDOUT_FILENO)) return;
        double pct = total_ > 0 ? double(current_) / double(total_) : 0.0;
        int filled = int(pct * int(width_));
        std::print("\r\033[2K[");
        for (int i = 0; i < int(width_); ++i)
            std::print("{}", i < filled ? '=' : ' ');
        std::print("] {:5.1f}%  {}/{}", pct * 100.0, current_, total_);
        if (current_ == total_) std::println("");
        std::cout.flush();
    }
};

Use \033[2K (erase entire line) before redrawing to avoid leftover characters when the new line is shorter than the previous one.


Interactive Prompts and Readline

Reading a single line of input with history and tab-completion requires either the system readline library or a portable single-header alternative. Avoid std::getline for interactive use — it has no history or editing.

cpp
// Option 1: GNU readline (LGPL, available on Linux/macOS)
#include <readline/readline.h>
#include <readline/history.h>

std::optional<std::string> prompt_readline(const char* ps1) {
    char* line = readline(ps1);
    if (!line) return std::nullopt;          // EOF / Ctrl-D
    if (*line) add_history(line);            // add non-empty lines to history
    std::string result{line};
    free(line);
    return result;
}

// Custom tab completion
char** my_completer(const char* text, int /*start*/, int /*end*/) {
    rl_attempted_completion_over = 1;        // suppress filename fallback
    return rl_completion_matches(text, [](const char* text, int state) -> char* {
        static const char* commands[] = {"help","quit","load","save", nullptr};
        static int idx;
        if (!state) idx = 0;
        while (commands[idx])
            if (std::string_view{commands[idx++]}.starts_with(text))
                return strdup(commands[idx-1]);
        return nullptr;
    });
}

void setup_readline() {
    rl_attempted_completion_function = my_completer;
    using_history();
    read_history(".mytool_history");   // persist history across sessions
}

// Option 2: linenoise (single-file, MIT, no GPL)
// https://github.com/antirez/linenoise — drop linenoise.h + linenoise.c into project
// char* line = linenoise("» ");
cmake
# Link readline
find_package(Readline REQUIRED)
target_link_libraries(mytool PRIVATE ${Readline_LIBRARIES})
target_include_directories(mytool PRIVATE ${Readline_INCLUDE_DIRS})

Configuration File Integration (TOML, INI, XDG)

Production CLI tools separate runtime flags (CLI args) from persistent preferences (config files), following XDG Base Directory conventions on Linux.

cpp
// XDG config path resolution
#include <filesystem>
namespace fs = std::filesystem;

fs::path xdg_config_dir(std::string_view app_name) {
    if (auto* xdg = std::getenv("XDG_CONFIG_HOME"))
        return fs::path{xdg} / app_name;
    if (auto* home = std::getenv("HOME"))
        return fs::path{home} / ".config" / app_name;
    return fs::path{"/etc"} / app_name;
}

fs::path find_config(std::string_view app_name) {
    // Search order: $PWD/.mytoolrc → XDG config → /etc/mytool/config.toml
    for (auto candidate : {
            fs::path{".mytoolrc"},
            xdg_config_dir(app_name) / "config.toml",
            fs::path{"/etc"} / app_name / "config.toml"
    }) {
        if (fs::exists(candidate)) return candidate;
    }
    return {};
}
cpp
// TOML11 (header-only, MIT)  — cmake: FetchContent toml11
#include <toml.hpp>

auto parse_toml_config(const fs::path& p) {
    auto data = toml::parse(p);
    struct Config {
        std::string output_dir;
        int         threads = 4;
        bool        color   = true;
    } cfg;
    cfg.output_dir = toml::find_or<std::string>(data, "output_dir", ".");
    cfg.threads    = toml::find_or<int>        (data, "threads",    4);
    cfg.color      = toml::find_or<bool>       (data, "color",      true);
    return cfg;
}
toml
# ~/.config/mytool/config.toml
output_dir = "/tmp/out"
threads    = 8
color      = true

[network]
timeout_ms = 5000

Exit Codes and Structured Error Reporting

Machines reading your tool's output deserve machine-readable errors. Structured error output combined with well-defined exit codes enables reliable scripting and CI integration.

cpp
#include <sysexits.h>   // BSD sysexits — widely adopted on Linux too

// Standard sysexits.h codes:
// EX_OK       = 0   success
// EX_USAGE    = 64  bad arguments
// EX_DATAERR  = 65  bad input data
// EX_NOINPUT  = 66  input file not found
// EX_NOUSER   = 67  user not found
// EX_NOHOST   = 68  host not found
// EX_UNAVAILABLE = 69  service unavailable
// EX_SOFTWARE = 70  internal software error
// EX_OSERR    = 71  OS error
// EX_IOERR    = 74  I/O error
// EX_TEMPFAIL = 75  temporary failure (retry is safe)
// EX_NOPERM   = 77  permission denied
// EX_CONFIG   = 78  bad config file

// Structured error output (JSON lines to stderr)
struct ToolError {
    int         code;
    std::string message;
    std::string file;
    int         line = 0;
};

void emit_error(const ToolError& e, bool json_mode) {
    if (json_mode) {
        // Machine-readable: one JSON object per line on stderr
        std::println(stderr,
            R"({{"level":"error","code":{},"message":"{}","file":"{}","line":{}}})",
            e.code, e.message, e.file, e.line);
    } else {
        // Human-readable
        if (!e.file.empty())
            std::println(stderr, "{}:{}: error: {}", e.file, e.line, e.message);
        else
            std::println(stderr, "error: {}", e.message);
    }
}

// Pattern: collect all errors before exiting so tools like CI see them all
std::vector<ToolError> errors;
// ...process...
for (auto& err : errors) emit_error(err, json_mode);
return errors.empty() ? EX_OK : EX_DATAERR;

Shell Completion Scripts

Distributing shell completion scripts turns a good CLI into a great one — users discover options via Tab rather than --help. CLI11 can generate them; otherwise write them manually or use a template.

bash
# CLI11: built-in completion generation
./mytool --help-all    # lists all options; CLI11 3.x has --completion
./mytool completion bash   # if you add a "completion" subcommand

# Manual bash completion script
# /usr/share/bash-completion/completions/mytool
_mytool_completions() {
    local cur="${COMP_WORDS[COMP_CWORD]}"
    local prev="${COMP_WORDS[COMP_CWORD-1]}"

    case "$prev" in
        --level|-l)  COMPREPLY=($(compgen -W "1 2 3 4 5 6 7 8 9" -- "$cur")); return ;;
        --output|-o) COMPREPLY=($(compgen -f -- "$cur")); return ;;
    esac

    if [[ "$cur" == -* ]]; then
        COMPREPLY=($(compgen -W "--output --level --force --verbose --help" -- "$cur"))
    else
        COMPREPLY=($(compgen -f -- "$cur"))   # file completion for positional args
    fi
}
complete -F _mytool_completions mytool
zsh
# Zsh completion: _mytool  → place in a $fpath directory
#compdef mytool
_mytool() {
    _arguments \
        '(-o --output)'{-o,--output}'[Output archive]:file:_files' \
        '(-l --level)'{-l,--level}'[Compression level]:level:(1 2 3 4 5 6 7 8 9)' \
        '(-f --force)'{-f,--force}'[Overwrite output]' \
        '*:input file:_files'
}
_mytool "$@"
fish
# Fish completion: ~/.config/fish/completions/mytool.fish
complete -c mytool -l output  -s o -d "Output archive"   -r -F
complete -c mytool -l level   -s l -d "Compression 1-9"  -r -a "1 2 3 4 5 6 7 8 9"
complete -c mytool -l force   -s f -d "Overwrite output"
complete -c mytool -l verbose -s v -d "Verbose output"
cmake
# Install completions via CMake
install(FILES completions/mytool.bash
    DESTINATION /usr/share/bash-completion/completions
    RENAME mytool)
install(FILES completions/_mytool
    DESTINATION /usr/share/zsh/vendor-completions)
install(FILES completions/mytool.fish
    DESTINATION /usr/share/fish/vendor_completions.d)

See Also