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)
#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;
}CLI11 (Popular Library)
// 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
#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
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
// 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)
// 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 exitSignal Handling
#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
// 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.
// --- 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);| Feature | CLI11 | cxxopts | argparse |
|---|---|---|---|
| Subcommands | yes | no | yes |
| Validators/checks | rich built-ins | manual | manual |
| Shell completion | via scripts | no | no |
| Config file integration | yes (.ini) | no | no |
| Header-only | yes | yes | yes |
| C++ standard required | C++11 | C++11 | C++17 |
| Approximate LoC | 7 000 | 1 200 | 3 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.
#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.
#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.
// 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("» ");# 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.
// 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 {};
}// 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;
}# ~/.config/mytool/config.toml
output_dir = "/tmp/out"
threads = 8
color = true
[network]
timeout_ms = 5000Exit 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.
#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.
# 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 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 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"# 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
- CLI11 documentation — subcommands, validators, config file integration
- no-color.org — environment variable spec for disabling ANSI color
- sysexits.h man page — canonical exit code reference
- XDG Base Directory Specification — config/data/cache path conventions
- GNU Readline manual — history, completion, and keybinding customization
- toml11 — TOML parsing library for C++17