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