Command-Line Programs in C

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

C is the language the command-line world is built on: the shell, ls, grep, git and the compilers are C programs, and the interface they all share — an argument vector, three standard streams, an environment, an exit status and a handful of signals — is the interface of C’s main. This page builds one complete C program, the Bookshelf shelf tool, and uses it to show every piece a real command-line program needs: parsing options, reading input until end of file, reporting errors with the right exit status, reading the environment, cleaning up when the user presses Ctrl+C, reading single keys in raw mode, and doing the same things on Windows, where the C runtime differs.

The page was written against glibc 2.44 (Linux), the macOS libc, the Microsoft C runtime (UCRT) and POSIX.1-2024. The shelf program, the error and input snippets and the raw-mode key reader were compiled with Apple clang 21 on macOS (with -Wall -Wextra -Wpedantic, and once with the address and undefined-behaviour sanitizers) and run there, including the key reader under a pseudo-terminal. These parts were not run while the page was written: everything that needs glibc (argp, secure_getenv, behaviour listed as "glibc" in the tables), the Windows sources (wmain, _setmode) and the CMake and Meson files, because no Linux, Windows, CMake or Meson installation was available. Each such snippet says so where it appears.

Several topics have their own pages and are linked rather than repeated. The process model (fork, exec, signals in depth, SIGPIPE) is on POSIX Processes, Signals & I/O; the terminal line discipline and termios theory on Terminals, TTYs & Pseudo-Terminals; the escape sequences used to draw on screen on Escape Sequences & Terminal Capabilities; the Windows console, code pages and argument splitting on Windows Console & Processes; and the language itself on the C Reference, in particular Program Structure, Input, Output and Files and Build and Tooling. How a command-line tool should behave (stream conventions, exit-code taxonomy, colour rules) is on Streams & Exit Codes and TTY, Colour & Interactivity; this page shows how C implements those rules.

The shelf program at a glance

shelf keeps a catalogue of books in a tab-separated file and manages it with subcommands.

Command What it does

shelf list [-l]

Prints the catalogue as a table; -l (--loaned) shows only the books that are lent out.

shelf add TITLE AUTHOR

Appends a book.

shelf import [FILE]

Adds one book per input line (Title|Author) from a file, or from standard input when FILE is omitted or -.

shelf loan ID NAME, shelf return ID

Lends a book to someone, or takes it back.

shelf pick

Shows the catalogue and lets the user choose a book with the arrow keys; prints the chosen id on standard output.

The global options are -f/--file, -v/--verbose, --color=auto|always|never, -h/--help and -V/--version. The catalogue file is $SHELF_HOME/books.tsv, or books.tsv in the current directory when SHELF_HOME is not set. The flow of the program, and the exit status of every way out of it, is:

flowchart TD A["main: getopt_long parses the global options up to the command"] -->|"unknown option or missing argument"| U["exit 64 EX_USAGE"] A --> B["want_color: --color, NO_COLOR, TERM, isatty"] B --> C["install_handlers: SIGINT, SIGTERM, SIGHUP set a flag, SIGPIPE ignored"] C --> D["load the catalogue"] D -->|"cannot open"| NI["exit 66 EX_NOINPUT"] D -->|"malformed line"| DE["exit 65 EX_DATAERR"] D --> E{"command"} E -->|"list"| L["print the table"] E -->|"add, import, loan, return"| M["change the catalogue, save through a temporary file and rename"] E -->|"pick"| P["raw mode, read keys, print the id"] E -->|"unknown"| U M -->|"write error"| IO["exit 74 EX_IOERR"] L --> F["flush stdout and check for errors"] M --> F P --> F P -.->|"signal"| S["restore the terminal, re-raise the signal: status 128+N"] F -->|"error"| IO F -->|"ok"| Z["exit 0"]

main and the exit status

A C program starts at main. A hosted implementation (a program running under an operating system, as every program on this page does) accepts the two forms that the C standard requires, and the platforms add a third.

Signature Meaning Portability

int main(void)

The program ignores its arguments.

C standard.

int main(int argc, char *argv[])

argv[0] is the name the program was started with (not necessarily its path, and it can be anything the caller chose), argv[1] to argv[argc - 1] are the arguments, and argv[argc] is a null pointer.

C standard; the form to use.

int main(int argc, char *argv[], char *envp[])

Adds the environment as a null-terminated array of NAME=value strings.

Common extension on UNIX and Windows; portable code reads environ or calls getenv.

int wmain(int argc, wchar_t *argv[])

The Microsoft entry point with UTF-16 arguments, selected automatically by the linker when the program defines it.

Windows (MSVC; MinGW-w64 needs the -municode option).

The arguments are bytes, not text. On Linux they are whatever bytes the caller passed, usually UTF-8 but not guaranteed to be; on macOS they are normally UTF-8; on Windows the narrow main receives them converted to the active ANSI code page, which loses characters outside it (see Windows: wmain, UTF-8 and the missing pieces).

The value that main returns is the program’s exit status. Returning from main is the same as calling exit with that value, and a main that reaches its closing brace without a return returns 0 (a C99 rule that applies to main only).

Way out Behaviour

return n; from main, exit(n)

Runs the functions registered with atexit, flushes and closes every open FILE, then ends the process with status n.

_exit(n) (POSIX), _Exit(n) ©

Ends the process immediately: no atexit functions, no flushing of stdio buffers. Safe inside a signal handler; the only way out of a forked child that must not touch the parent’s buffers.

abort()

Raises SIGABRT; the process is killed by a signal and may dump core.

Death by signal

There is no exit status; the parent sees "killed by signal N", which a shell reports as 128 + N.

Only EXIT_SUCCESS (0) and EXIT_FAILURE are portable names for the status in standard C. POSIX defines that a parent receives the low eight bits, so a status above 255 is truncated (exit(256) looks like success). Shells add their own conventions on top: 126 (found but not executable), 127 (not found) and 128 + N (killed by signal N). Windows uses a 32-bit exit code, but a program that wants to be scripted from both worlds stays within 0 to 125. The exit-code taxonomy for tools that scripts and agents call is on Streams & Exit Codes; how main, exit and the status work in the language is on Program Structure.

shelf returns one of a small set of named statuses (see Reporting errors) and, before it returns, checks that its output really reached the destination. A program that prints with printf and ignores the result reports success even when the disk is full or the pipe is closed. The last statements of the main function of shelf are:

    if (fflush(stdout) != 0 || ferror(stdout)) {           /* `shelf list > /dev/full` */
        if (errno != EPIPE) {                              /* a closed pipe is the reader's choice */
            fprintf(stderr, "%s: write error: %s\n", prog, strerror(errno));
        }
        return EX_IOERR;
    }
    return EXIT_SUCCESS;

On Linux, /dev/full is a device whose writes always fail with ENOSPC, which makes the check testable (shelf list > /dev/full exits with 74 and a "No space left on device" message); macOS has no /dev/full, so that exact test was not run. A closed pipe was tested on macOS: shelf list | head -n 1 leaves shelf with EPIPE and exit status 74 but, deliberately, no message.

Parsing options

Every command-line tool needs the same decisions: which spellings are accepted (-v, --verbose, -vf FILE, --file=FILE), whether options may follow operands, what happens with --, and what the program does about an unknown option. C offers three standard-library-level answers and, on Windows, none of them. The rules a tool should follow are the POSIX Utility Syntax Guidelines, which the POSIX page turns into an implementation checklist, and the GNU Coding Standards for long options, --help and --version.

getopt getopt_long argp

Defined by

POSIX.1-2024 (<unistd.h>)

GNU extension, copied by the BSDs and macOS (<getopt.h>); not in POSIX

GNU extension of glibc (<argp.h>)

Short options

Yes

Yes

Yes

Long options, --opt=value

No

Yes, with unambiguous abbreviations

Yes, with unambiguous abbreviations

Generates --help, --version, usage

No

No

Yes, including formatted option tables and the bug-report address

Available on

Linux, macOS, BSDs; not in the MSVC runtime

Linux, macOS, BSDs, MinGW-w64, Cygwin and MSYS2; not in the MSVC runtime

glibc only; not in the macOS libc, not in musl, not in the MSVC runtime

getopt

POSIX getopt takes the option string (for example qvo:: letters, a colon after an option that takes an argument) and returns one option character per call, setting optarg to the argument, optind to the index of the next argv element and optopt to the offending character on an error. It stops at the first operand (POSIX behaviour) or at --. glibc permutes argv so that options after operands are still found, unless the option string starts with + or the environment variable POSIXLY_CORRECT is set. A complete getopt loop, and the guideline-by-guideline checklist it implements, are on POSIX Processes, Signals & I/O. It handles short options only, which is why most tools use getopt_long.

getopt_long

getopt_long adds a table of long options. Each struct option entry has a name, an argument kind (no_argument, required_argument or optional_argument), a flag pointer and a val. When flag is null the function returns val, which is usually the short option’s character or, for a long-only option, a number above 255; when flag points to an int the function stores val there and returns 0.

Element Behaviour

Option string, first character +

Stop at the first operand, as POSIX does. Needed for shelf, whose global options come before the command and whose subcommands have options of their own.

Option string, first character -

Return every operand as if it were the argument of an option with the character code 1 (glibc).

Option string, : first (after + or -)

Return : instead of ? when an option argument is missing, so the program can tell "missing argument" from "unknown option"; also silences the library’s own messages.

no_argument, required_argument

--color=never and --color never both work for a required argument.

optional_argument

The argument must be attached (--color=never); --color never leaves never as an operand.

Abbreviations

--verb is accepted for --verbose while it is unambiguous. A program that adds a new long option can make an existing abbreviation ambiguous, so scripts should use the full names.

optarg, optind, optopt, opterr

Globals shared with getopt. Not thread-safe; opterr = 0 silences the library’s messages.

Unknown long option

Returns ?, and optopt is not useful: use argv[optind - 1] to name the culprit.

--

Ends the options; optind is advanced past it.

The messages the library prints for an unknown option differ between C libraries (macOS prints unrecognized option ... with a back-quote and an apostrophe; other libraries use two apostrophes), so a program that needs stable messages, such as shelf, uses the leading : and writes its own. The main loop of shelf:

int main(int argc, char *argv[])
{
    const char *slash = strrchr(argv[0], '/');
    prog = slash ? slash + 1 : argv[0];

    enum { OPT_COLOR = 256 };
    static const struct option long_opts[] = {
        { "file",    required_argument, NULL, 'f' },
        { "verbose", no_argument,       NULL, 'v' },
        { "color",   required_argument, NULL, OPT_COLOR },
        { "help",    no_argument,       NULL, 'h' },
        { "version", no_argument,       NULL, 'V' },
        { 0 }
    };

    struct options o = { 0, 0, NULL };
    const char *color_when = "auto";
    int ch;
    while ((ch = getopt_long(argc, argv, "+:f:vhV", long_opts, NULL)) != -1) {
        switch (ch) {
        case 'f': o.db = optarg; break;
        case 'v': o.verbose = 1; break;
        case OPT_COLOR: color_when = optarg; break;
        case 'h': usage(stdout); return EXIT_SUCCESS;
        case 'V': printf("%s %s\n", prog, VERSION); return EXIT_SUCCESS;
        case ':': die(EX_USAGE, "option '%s' needs an argument", argv[optind - 1]);
        default:  die(EX_USAGE, "unknown option '%s' (try --help)", argv[optind - 1]);
        }
    }
    argc -= optind;
    argv += optind;
    if (argc == 0) { usage(stderr); return EX_USAGE; }

    o.color = want_color(color_when);
    char path[PATH_MAX];
    if (o.db == NULL) {
        const char *home = env_get("SHELF_HOME");
        int n = snprintf(path, sizeof path, "%s%sbooks.tsv", home ? home : "", home ? "/" : "");
        if (n < 0 || (size_t)n >= sizeof path) die(EX_USAGE, "SHELF_HOME is too long");
        o.db = path;
    }
    install_handlers();

    struct catalogue c = { 0 };
    load(&c, o.db);

    const char *cmd = argv[0];
    if      (strcmp(cmd, "list")   == 0) cmd_list(&o, argc, argv, &c);
    else if (strcmp(cmd, "add")    == 0) cmd_add(&o, argc, argv, &c);
    else if (strcmp(cmd, "import") == 0) cmd_import(&o, argc, argv, &c);
    else if (strcmp(cmd, "loan")   == 0) cmd_loan(&o, argc, argv, &c);
    else if (strcmp(cmd, "return") == 0) cmd_return(&o, argc, argv, &c);
    else if (strcmp(cmd, "pick")   == 0) cmd_pick(&o, argc, argv, &c);
    else die(EX_USAGE, "unknown command '%s' (try --help)", cmd);

    if (fflush(stdout) != 0 || ferror(stdout)) {           /* `shelf list > /dev/full` */
        if (errno != EPIPE) {                              /* a closed pipe is the reader's choice */
            fprintf(stderr, "%s: write error: %s\n", prog, strerror(errno));
        }
        return EX_IOERR;
    }
    return EXIT_SUCCESS;
}

A tool with subcommands runs getopt_long again for each subcommand’s own options. The library keeps its position in global variables, so the second parse has to reset them, and the way to do that differs:

Platform Reset before the next parse

glibc (Linux)

optind = 0. A value of 0 (not 1) makes the library re-read the option string and POSIXLY_CORRECT, which is required when the option string changes.

macOS, FreeBSD, OpenBSD, NetBSD

optreset = 1; optind = 1;

Windows with MinGW-w64, Cygwin, MSYS2

The behaviour of the library that ships with the toolchain; test it.

static void reset_getopt(void)
{
#if defined(__APPLE__) || defined(__FreeBSD__) || defined(__OpenBSD__) || defined(__NetBSD__)
    optreset = 1;
    optind = 1;
#else
    optind = 0;                                /* glibc: 0 also re-initialises internal state */
#endif
}
static void cmd_list(const struct options *o, int argc, char **argv, struct catalogue *c)
{
    static const struct option lo[] = { { "loaned", no_argument, NULL, 'l' }, { 0 } };
    int only_loaned = 0, ch;
    reset_getopt();
    while ((ch = getopt_long(argc, argv, "+:l", lo, NULL)) != -1) {
        if (ch == 'l') only_loaned = 1;
        else die(EX_USAGE, "list: unknown option '%s'", argv[optind - 1]);
    }
    if (optind != argc) die(EX_USAGE, "list: unexpected argument '%s'", argv[optind]);
    printf(o->color ? "\x1b[1m%4s  %-32s %-20s %s\x1b[0m\n" : "%4s  %-32s %-20s %s\n",
           "ID", "TITLE", "AUTHOR", "LOANED TO");
    for (size_t i = 0; i < c->n; i++) {
        const struct book *b = &c->v[i];
        if (only_loaned && *b->loaned_to == '\0') continue;
        printf("%4ld  %-32s %-20s %s\n", b->id, b->title, b->author, b->loaned_to);
    }
}
$ shelf list --loaned
  ID  TITLE                            AUTHOR               LOANED TO
   2  Emma                             Jane Austen          Ana
$ shelf list -x
shelf: list: unknown option '-x'
$ shelf --verb --file=books.tsv list      # unambiguous abbreviation, long option with attached argument
  ID  TITLE                            AUTHOR               LOANED TO
   1  Dune                             Frank Herbert
$ shelf -vf books.tsv list                # grouped short options, the last one takes the argument
  ID  TITLE                            AUTHOR               LOANED TO
   1  Dune                             Frank Herbert
$ shelf list -- -l                        # after --, -l is an operand
shelf: list: unexpected argument '-l'
$ echo $?
64

Source: The GNU C Library — Parsing Long Options with getopt_long and getopt(3) for the option-string prefixes, POSIXLY_CORRECT and the reset rule. Compiled and run on macOS with the BSD optreset branch; the optind = 0 branch was not run.

Numbers and other typed arguments

atoi cannot report an error. strtol can, if you ask it to: clear errno first, check that it consumed the whole string, and check ERANGE. shelf rejects abc, 12x, an empty string, an out-of-range number and anything below 1 with one status, 64:

static long parse_id(const char *s)
{
    char *end;
    errno = 0;
    long v = strtol(s, &end, 10);
    if (end == s || *end != '\0' || errno == ERANGE || v < 1) {
        die(EX_USAGE, "invalid book id '%s'", s);
    }
    return v;
}

argp

argp (glibc, <argp.h>) is a declarative alternative: the program describes its options in a table and supplies one callback, and the library parses, validates and generates the --help, --usage and --version output.

Piece Purpose

struct argp_option table

Name, key (the short option character, or a number for a long-only option), argument name, flags, documentation and group, ending with an all-zero entry. Flags include OPTION_ARG_OPTIONAL, OPTION_HIDDEN and OPTION_ALIAS.

struct argp

The option table, the parser callback, a one-line description of the operands (args_doc) and a documentation string. A vertical tab (\v) in the documentation string separates the text shown before the option list from the text shown after it.

Parser callback

Called with the option key and arg, plus special keys: ARGP_KEY_ARG (one operand), ARGP_KEY_ARGS (all the remaining operands, at state->argv + state->next), ARGP_KEY_NO_ARGS, ARGP_KEY_END, ARGP_KEY_INIT and ARGP_KEY_FINI. It returns 0, ARGP_ERR_UNKNOWN or an errno value.

argp_program_version, argp_program_bug_address

Global variables that make argp add --version and print "Report bugs to …​" at the end of --help.

argp_err_exit_status

Exit status after a parse error; defaults to EX_USAGE.

argp_parse flags

ARGP_IN_ORDER (do not permute), ARGP_NO_HELP, ARGP_NO_EXIT, ARGP_NO_ERRS, ARGP_SILENT, ARGP_LONG_ONLY (discouraged).

argp_usage, argp_error

Print usage or an error through the library, and exit unless ARGP_NO_EXIT is set.

The program below parses the global options of shelf with argp and leaves the command and its arguments in ARGP_KEY_ARGS.

/* shelf_argp.c -- the global options of shelf with glibc argp (Linux only). */
#define _GNU_SOURCE
#include <argp.h>
#include <stdio.h>
#include <stdlib.h>

const char *argp_program_version = "shelf 0.3.0";            /* adds --version / -V */
const char *argp_program_bug_address = "<shelf-bugs@example.org>";

static const char doc[] = "Manage a small bookshelf.\vCommands: list, add, import, loan, return, pick.";
static const char args_doc[] = "COMMAND [ARG]...";

static const struct argp_option options[] = {
    { "file",    'f', "FILE", 0, "Catalogue file", 0 },
    { "verbose", 'v', NULL,   0, "Report what is being done on stderr", 0 },
    { "color",   'c', "WHEN", 0, "auto, always or never (default: auto)", 0 },
    { NULL, 0, NULL, 0, NULL, 0 }
};

struct arguments {
    const char *file;
    const char *color;
    int verbose;
    char **rest;                                              /* the command and its arguments */
    int nrest;
};

static error_t parse_opt(int key, char *arg, struct argp_state *state)
{
    struct arguments *a = state->input;
    switch (key) {
    case 'f': a->file = arg; break;
    case 'v': a->verbose = 1; break;
    case 'c': a->color = arg; break;
    case ARGP_KEY_ARGS:                                       /* everything that is not an option */
        a->rest = state->argv + state->next;
        a->nrest = state->argc - state->next;
        break;
    case ARGP_KEY_END:
        if (a->nrest == 0) {
            argp_usage(state);                                /* prints usage, exits with EX_USAGE */
        }
        break;
    default:
        return ARGP_ERR_UNKNOWN;
    }
    return 0;
}

static const struct argp argp = { options, parse_opt, args_doc, doc, NULL, NULL, NULL };

int main(int argc, char *argv[])
{
    struct arguments a = { NULL, "auto", 0, NULL, 0 };
    argp_parse(&argp, argc, argv, ARGP_IN_ORDER, NULL, &a);   /* exits on --help, --version and errors */
    printf("command = %s, %d argument(s), verbose = %d\n", a.rest[0], a.nrest - 1, a.verbose);
    return EXIT_SUCCESS;
}

Source: The GNU C Library — Parsing Program Options with Argp (see Argp Special Keys and Flags for argp_parse there). This program was not compiled or run: argp does not exist in the macOS libc. How options that follow the command name behave with ARGP_IN_ORDER is not spelled out in the manual, so a tool with per-subcommand options should test it on its target libc or use getopt_long with a leading + as shelf does. The manual says nothing about Windows or musl; programs that must build there bundle a standalone argp implementation or avoid it.

Choosing

  • A tool that must build everywhere uses getopt_long plus a short vendored implementation for MSVC, or a library (CLI11 for C++, see Command-Line Programs in C++).

  • A tool that only targets glibc and wants generated help takes argp.

  • A tool with three options and no subcommands can use getopt and the POSIX behaviour.

  • Writing the loop by hand over argv is reasonable for a Windows-first tool that accepts /flag spellings, as long as it still handles -- and - the way users expect.

Reading standard input

A filter reads standard input until the end of the file. The C interface is simple and the pitfalls are all in the edges.

Function When to use it, and the catch

getline (POSIX.1-2008, <stdio.h>)

One line of any length into a growing heap buffer; returns the length or -1. The line may lack its final newline, and may contain NUL bytes (compare with the returned length, not strlen). Not in the MSVC runtime.

fgets

Standard C; a fixed buffer. A line longer than the buffer comes back in pieces, so the caller has to detect that the newline is missing; the usual bug is treating the second half of a long line as a new line.

fread and read

Raw bytes, for binary input and for the raw-mode reader below. read(2) bypasses the stdio buffer, so never mix it with getchar on the same descriptor.

scanf family

Avoid for lines of text; see Input, Output and Files.

getline returns -1 both at end of file and on a read error; the two are told apart with ferror afterwards. Interactive programs also want to know whether anyone is typing: isatty(STDIN_FILENO) (declared in <unistd.h>, _isatty in <io.h> on Windows) decides whether to print a prompt, and shelf import prints a hint on standard error only when standard input is a terminal, so a pipeline stays silent. The full non-TTY fallback rules for tools that agents run are on TTY, Colour & Interactivity.

The read loop of shelf import, including the interruption by a signal described in Signals and cleanup:

static void cmd_import(const struct options *o, int argc, char **argv, struct catalogue *c)
{
    if (argc > 2) die(EX_USAGE, "import: at most one FILE");
    FILE *in = stdin;
    const char *name = "standard input";
    if (argc == 2 && strcmp(argv[1], "-") != 0) {
        in = fopen(argv[1], "r");
        if (in == NULL) die_errno(EX_NOINPUT, "cannot open", argv[1]);
        name = argv[1];
    } else if (isatty(STDIN_FILENO)) {
        fprintf(stderr, "%s: reading from the terminal; finish with Ctrl+D\n", prog);
    }
    char *line = NULL;
    size_t cap = 0;
    ssize_t len;
    size_t added = 0;
    for (;;) {
        errno = 0;
        len = getline(&line, &cap, in);
        if (len == -1) {
            if (errno == EINTR && got_signal) die_from_signal();
            break;                             /* EOF or error: told apart below */
        }
        if (len > 0 && line[len - 1] == '\n') line[len - 1] = '\0';
        if (*line == '\0') continue;
        char *bar = strchr(line, '|');
        if (bar == NULL) die(EX_DATAERR, "%s: missing '|' in '%s'", name, line);
        *bar = '\0';
        if (has_forbidden(line) || has_forbidden(bar + 1)) die(EX_DATAERR, "%s: control characters are not allowed", name);
        push(c, next_id(c), line, bar + 1, "");
        added++;
    }
    if (ferror(in)) die_errno(EX_IOERR, "read error on", name);   /* EOF is not an error */
    free(line);
    if (in != stdin) fclose(in);
    save(c, o->db);
    if (o->verbose) fprintf(stderr, "%s: imported %zu book(s)\n", prog, added);
}

A smaller program shows the edges on their own. It was run with a final line without a newline, with empty input, with a 100,000-byte line and with a directory as input:

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main(void)
{
    if (isatty(STDIN_FILENO)) {
        fputs("type titles, one per line; Ctrl+D ends\n", stderr);
    }
    char *line = NULL;
    size_t cap = 0, count = 0, longest = 0;
    ssize_t len;
    while ((len = getline(&line, &cap, stdin)) != -1) {      /* grows the buffer as needed */
        if (len > 0 && line[len - 1] == '\n') line[--len] = '\0';   /* the last line may have no \n */
        count++;
        if ((size_t)len > longest) longest = (size_t)len;
    }
    if (ferror(stdin)) {                                     /* -1 means EOF *or* error */
        perror("read");
        return EXIT_FAILURE;
    }
    printf("%zu line(s), longest %zu byte(s)\n", count, longest);
    free(line);
    return EXIT_SUCCESS;
}
$ printf 'Dune\nEmma\nUlysses without newline' | ./reader
3 line(s), longest 23 byte(s)
$ ./reader < /dev/null
0 line(s), longest 0 byte(s)
$ python3 -c "print('x' * 100000)" | ./reader
1 line(s), longest 100000 byte(s)
$ ./reader < .
read: Is a directory

Buffering and setvbuf

The C library buffers each stream: standard output is line-buffered when it is a terminal and fully buffered when it is a pipe or a file, and standard error is unbuffered. The consequences (a progress line that appears late, two streams that interleave out of order, output lost when a program dies from a signal) are explained on POSIX Processes, Signals & I/O. setvbuf(stream, buf, mode, size) changes the mode (_IOFBF fully, _IOLBF line, _IONBF none). It must be called before any other operation on the stream, and a buffer that the caller supplies must outlive the stream. A filter that has to hand results to the next program in a pipeline as soon as each line is ready makes standard output line-buffered:

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

/* Echo each title as soon as it arrives, even when stdout is a pipe. */
int main(void)
{
    if (setvbuf(stdout, NULL, _IOLBF, 0) != 0) {    /* must run before any other use of stdout */
        perror("setvbuf");
        return EXIT_FAILURE;
    }
    char *line = NULL;
    size_t cap = 0;
    while (getline(&line, &cap, stdin) != -1) {
        fputs(line, stdout);                        /* flushed at each newline */
    }
    free(line);
    return ferror(stdin) || fflush(stdout) != 0 ? EXIT_FAILURE : EXIT_SUCCESS;
}

Run as (echo a; sleep 1; echo b) | ./lines | while read l; do echo "$(date +%S) $l"; done, it printed a and b one second apart; without the setvbuf call both lines arrive together when the program exits. Line-buffering a high-volume output makes it slower, because each line costs a write system call, so reserve it for the streaming case.

Source: POSIX — setvbuf. Compiled and run on macOS.

On Windows the standard streams are in text mode: \n is written as \r\n, and on input a Ctrl+Z (character 26) read from the console ends the file. A program that writes binary data or a protocol with exact bytes switches the descriptor to binary with _setmode(_fileno(stdout), _O_BINARY) (<io.h> and <fcntl.h>).

Reporting errors

errno, perror and strerror

A failing system or library call returns a sentinel (-1 or a null pointer) and sets errno. Three rules cover most mistakes: read errno only right after a call that reported failure, because successful calls may change it; copy it before calling anything else (a fprintf can overwrite it, which is why die_errno below saves it first); and never assign to it except to clear it before strtol-style calls.

Function Behaviour

perror(prefix)

Prints prefix: message and a newline on standard error. The prefix is whatever the caller chooses; use the program name.

strerror(n)

Returns the message in a string that a later call may overwrite and that other threads share; fine for a single-threaded tool, avoid in libraries.

strerror_r

The thread-safe form, with two incompatible versions: the XSI/POSIX one returns an int and fills the buffer, and the GNU one (what glibc declares when _GNU_SOURCE is defined) returns a char * that may point to a static string. Code that must compile against both uses the return type it gets.

err, warn, errx, warnx (<err.h>)

BSD functions, also in glibc, not in POSIX and not in the MSVC runtime. They prefix the message with the program name and err/errx exit. Handy on UNIX; unavailable on Windows.

strerror_s

The C11 Annex K / MSVC form on Windows.

Windows has a second error domain: Win32 functions report failures through GetLastError, not errno, and the CRT functions use errno. A Windows port has to know which family each call belongs to. The compiled snippet below uses the UNIX functions and prints what they produce on macOS:

#define _POSIX_C_SOURCE 200809L
#include <err.h>        /* BSD and glibc, not POSIX */
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sysexits.h>   /* BSD origin, present on Linux and macOS, absent from MSVC */

int main(int argc, char *argv[])
{
    (void)argc;
    FILE *f = fopen("/nonexistent/books.tsv", "r");
    if (f == NULL) {
        int saved = errno;                       /* copy before any call that may change it */

        perror("shelf");                         /* "shelf: No such file or directory" */

        char buf[128];
        if (strerror_r(saved, buf, sizeof buf) == 0) {   /* XSI form: int return */
            printf("strerror_r: %s\n", buf);
        }
        printf("EX_USAGE=%d EX_DATAERR=%d EX_NOINPUT=%d EX_UNAVAILABLE=%d EX_SOFTWARE=%d EX_IOERR=%d EX_CONFIG=%d\n",
               EX_USAGE, EX_DATAERR, EX_NOINPUT, EX_UNAVAILABLE, EX_SOFTWARE, EX_IOERR, EX_CONFIG);

        errno = saved;
        warn("cannot open %s", argv[0]);         /* "<progname>: cannot open <arg>: <strerror>" */
        errno = saved;
        err(EX_NOINPUT, "giving up");            /* like warn, then exit(EX_NOINPUT) */
    }
    return EXIT_SUCCESS;
}
$ ./errors
shelf: No such file or directory
errors: cannot open ./errors: No such file or directory
errors: giving up: No such file or directory
$ echo $?
66

The standard output lines (strerror_r: ... and the status table) are printed too; the example above shows only the standard-error lines, in the order a terminal displays them.

Exit statuses and sysexits.h

<sysexits.h> names the exit codes 64 to 78 that mail transfer agents have used since 4BSD: EX_USAGE (64), EX_DATAERR (65), EX_NOINPUT (66), EX_UNAVAILABLE (69), EX_SOFTWARE (70), EX_OSERR (71), EX_CANTCREAT (73), EX_IOERR (74), EX_TEMPFAIL (75), EX_NOPERM (77) and EX_CONFIG (78). The header is present on Linux and macOS and absent from MSVC. The FreeBSD manual page that documents it marks the interface deprecated, discouraged for new code and not portable; glibc’s argp nevertheless uses EX_USAGE by default. shelf uses a handful of the names as a readable vocabulary, with a fallback definition for systems that lack the header (the __has_include test in the listing in The complete shelf program), and lists them in its --help output so that callers know what to expect. Whether a tool adopts them or just uses 1 and 2 is a design choice discussed on Streams & Exit Codes; what matters is that the codes are documented and stable.

The two helpers that every error in shelf goes through:

static void die(int status, const char *fmt, ...)
{
    va_list ap;
    fprintf(stderr, "%s: ", prog);
    va_start(ap, fmt);
    vfprintf(stderr, fmt, ap);
    va_end(ap);
    fputc('\n', stderr);
    exit(status);
}
static void die_errno(int status, const char *what, const char *path)
{
    int saved = errno;              /* fprintf may change errno */
    fprintf(stderr, "%s: %s '%s': %s\n", prog, what, path, strerror(saved));
    exit(status);
}

Messages follow the GNU convention program: message, one line, on standard error, and name the object that failed (cannot open 'books.tsv': No such file or directory).

Errors on the write path

Output errors appear late. fprintf to a full disk may succeed because the data only sits in the buffer; the failure surfaces at fflush, fclose or exit. shelf therefore checks fflush, ferror and fclose when it writes the catalogue (see save in The complete shelf program), and checks standard output at the end of main (shown earlier). It writes the new catalogue to a temporary file created with mkstemp in the same directory (an unpredictable name, mode 0600) and then calls rename to put it over the catalogue, which replaces the old file atomically, so a crash or a full disk never leaves a half-written catalogue behind.

The environment

getenv(name) returns a pointer into the process environment, or a null pointer. The pointer is only valid until the next call to setenv, putenv or unsetenv, the returned string must not be modified, and getenv is not async-signal-safe. Copy the value if it has to live longer. setenv(name, value, overwrite) changes the environment of the program and of the children it starts later; it is not thread-safe against concurrent readers, so a program sets variables before it starts threads. The complete tour of environ and envp is on POSIX Processes, Signals & I/O.

Trusting the environment

A set-user-ID or set-group-ID program that trusts getenv hands an attacker control of PATH, HOME or a configuration directory. glibc 2.17 added secure_getenv, which returns a null pointer when the process runs in secure-execution mode (set-user-ID, set-group-ID or elevated capabilities), and POSIX.1-2024 standardised it. It is meant for libraries and for any code that might run in such a process. The macOS libc used for this page does not declare it, so portable code asks the build system (HAVE_SECURE_GETENV, set by the CMake and Meson files below) and falls back to getenv, as shelf does:

/* CMake or Meson defines HAVE_SECURE_GETENV when the C library provides it (glibc 2.17+). */
#ifdef HAVE_SECURE_GETENV
#  define env_get secure_getenv
#else
#  define env_get getenv
#endif

Compiling that file with -DHAVE_SECURE_GETENV on macOS fails with an undeclared function, which confirms the point; the secure_getenv branch itself was not run.

Variables a CLI should read

shelf reads SHELF_HOME for its data location, and the standard ones for presentation: NO_COLOR (any non-empty value disables colour; a community convention documented at no-color.org, not a standard), and TERM (dumb means no escape sequences). The precedence rule is always flag over environment over default; the colour decision is:

static int want_color(const char *when)
{
    if (strcmp(when, "always") == 0) return 1;
    if (strcmp(when, "never") == 0) return 0;
    if (strcmp(when, "auto") != 0) die(EX_USAGE, "invalid --color argument '%s'", when);
    const char *no_color = env_get("NO_COLOR"), *term = env_get("TERM");
    if (no_color != NULL && *no_color != '\0') return 0;   /* no-color.org: non-empty disables */
    if (term == NULL || strcmp(term, "dumb") == 0) return 0;
    return isatty(STDOUT_FILENO);
}

The reasons behind these rules, and the other variables (FORCE_COLOR, CLICOLOR, COLUMNS), are on TTY, Colour & Interactivity and Configuration & Auth.

Windows

Environment variable names are case-insensitive on Windows and case-sensitive elsewhere. MSVC marks getenv as unsafe (warning C4996) and offers _dupenv_s (and _wdupenv_s for UTF-16), which allocate a copy that the caller frees. The envp array handed to main or wmain is a frozen copy: calling _putenv later changes what getenv returns but not envp.

Signals and cleanup

A command-line program has resources that must not be left behind: a terminal in raw mode, a temporary file, a lock. The sequence is the same everywhere:

  1. A handler records that a signal arrived and does nothing else.

  2. The main code sees the flag at a safe point (after an interrupted read returns EINTR, or in the loop’s condition), restores whatever it changed, and exits.

  3. To keep the correct exit status, the program resets the signal to its default action and sends it to itself, so the parent sees that the program was killed by SIGINT, and a shell shows 128 + 2, which shells and build tools use to stop a loop on Ctrl+C. A program that merely calls exit(1) after Ctrl+C looks like an ordinary failure.

The process model behind this (what a handler may call, SA_RESTART, the self-pipe trick) is on POSIX Processes, Signals & I/O; the three functions that shelf uses are:

static void on_signal(int sig)
{
    got_signal = sig;                          /* the only thing a handler does */
}
static void install_handlers(void)
{
    struct sigaction sa;
    memset(&sa, 0, sizeof sa);
    sa.sa_handler = on_signal;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;                           /* no SA_RESTART: blocking reads see EINTR */
    sigaction(SIGINT, &sa, NULL);
    sigaction(SIGTERM, &sa, NULL);
    sigaction(SIGHUP, &sa, NULL);

    sa.sa_handler = SIG_IGN;                   /* a closed pipe becomes EPIPE */
    sigaction(SIGPIPE, &sa, NULL);
}
static void die_from_signal(void)
{
    int sig = (int)got_signal;
    raw_off();
    signal(sig, SIG_DFL);
    raise(sig);
    _exit(128 + sig);                          /* only reached if raise returned */
}

Three details are deliberate. sa_flags is 0, without SA_RESTART, so that a blocking read or getline returns EINTR and the program notices the flag immediately instead of waiting for the next line. SIGPIPE is ignored so that a closed pipe becomes an EPIPE error from write that the code checks, which is usually easier to handle than dying silently (tools that behave like classic filters keep the default and die quietly). And got_signal is a volatile sig_atomic_t, the only kind of object a handler may portably write.

The behaviour was tested on macOS by sending SIGTERM and SIGINT to a shelf import blocked on an empty pipe: in both cases the process ended as "killed by the signal" (a parent sees -15 and -2; a shell shows 143 and 130), and for the pick command under a pseudo-terminal the terminal settings were restored first.

Windows has no sigaction. The CRT signal supports a few signals (SIGINT, SIGTERM, SIGABRT and a few more) and console programs handle Ctrl+C, Ctrl+Break and the closing of the window with SetConsoleCtrlHandler, which runs the handler on a new thread; the details are on Windows Console & Processes.

Reading single keys in raw mode

By default a terminal is in canonical mode: the kernel collects a line, lets the user edit it, and delivers it only when Enter is pressed. A menu such as shelf pick needs each key at once, which means switching the terminal to non-canonical mode with termios. The theory (the line discipline, every flag, cfmakeraw) is on Terminals, TTYs & Pseudo-Terminals; this is the minimal version a C tool needs.

Setting Why

Clear ICANON

Deliver bytes as they are typed instead of whole lines.

Clear ECHO

The program draws what it wants; the terminal must not echo keys.

Keep ISIG

Ctrl+C still sends SIGINT and Ctrl+Z SIGTSTP, so the user can always get out. A program that clears it takes responsibility for handling the byte 3 itself.

c_cc[VMIN] = 1, c_cc[VTIME] = 0

read blocks until one byte is available, with no timeout.

tcsetattr(..., TCSAFLUSH, ...)

Applies the change after pending output is written and discards unread input, so keys typed before the switch do not leak into the menu.

Restore on every exit path

atexit for normal exits, an explicit call before re-raising a signal, and a check that stdin is a terminal first. A process killed by SIGKILL cannot restore anything; the user fixes the terminal with stty sane or reset.

static void raw_on(void)
{
    struct termios t;
    if (tcgetattr(STDIN_FILENO, &saved_tio) == -1) {
        die_errno(EX_IOERR, "tcgetattr", "stdin");
    }
    t = saved_tio;
    t.c_lflag &= ~(tcflag_t)(ICANON | ECHO);   /* keep ISIG: Ctrl+C still works */
    t.c_cc[VMIN] = 1;
    t.c_cc[VTIME] = 0;
    if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &t) == -1) {
        die_errno(EX_IOERR, "tcsetattr", "stdin");
    }
    raw_active = 1;
    atexit(raw_off);                           /* every normal exit path restores */
}
static void raw_off(void)
{
    if (raw_active) {
        tcsetattr(STDIN_FILENO, TCSAFLUSH, &saved_tio);
        raw_active = 0;
    }
}

Arrow keys arrive as escape sequences, not single bytes: the sequence ESC [ A for Up, ESC [ B for Down (and ESC O A and ESC O B when the terminal is in application cursor-key mode). A lone Esc key sends only the byte 27, so the reader cannot tell "Esc" from "the start of an arrow" without a timeout. shelf waits 50 ms with poll for the next byte:

static int read_key(void)
{
    unsigned char ch;
    for (;;) {
        ssize_t n = read(STDIN_FILENO, &ch, 1);
        if (n == 1) break;
        if (n == 0) return -1;
        if (errno == EINTR) { if (got_signal) die_from_signal(); continue; }
        die_errno(EX_IOERR, "read error on", "terminal");
    }
    if (ch != 0x1b) return ch;
    struct pollfd p = { STDIN_FILENO, POLLIN, 0 };
    unsigned char seq[2];
    if (poll(&p, 1, 50) <= 0 || read(STDIN_FILENO, &seq[0], 1) != 1) return 0x1b;   /* a lone Esc */
    if (seq[0] != '[' && seq[0] != 'O') return 0x1b;
    if (poll(&p, 1, 50) <= 0 || read(STDIN_FILENO, &seq[1], 1) != 1) return 0x1b;
    return seq[1] == 'A' ? KEY_UP : seq[1] == 'B' ? KEY_DOWN : 0;
}

The menu itself redraws in place with two escape sequences (\x1b[nA moves the cursor up n lines and \x1b[2K clears a line) and highlights the selected row with reverse video (\x1b[7m). It draws on standard error and prints only the chosen id on standard output, so that id=$(shelf pick) works while the menu is shown on the terminal:

static void cmd_pick(const struct options *o, int argc, char **argv, struct catalogue *c)
{
    (void)o; (void)argv;
    if (argc != 1) die(EX_USAGE, "pick: takes no arguments");
    if (!isatty(STDIN_FILENO) || !isatty(STDERR_FILENO)) die(EX_USAGE, "pick needs a terminal on stdin and stderr");
    if (c->n == 0) die(EX_NOINPUT, "the shelf is empty");
    size_t sel = 0;
    raw_on();
    for (int first = 1;; first = 0) {
        if (!first) fprintf(stderr, "\x1b[%zuA", c->n);         /* back to the first row */
        for (size_t i = 0; i < c->n; i++) {
            fprintf(stderr, "\r\x1b[2K%s%4ld  %s\x1b[0m\n", i == sel ? "\x1b[7m" : "", c->v[i].id, c->v[i].title);
        }
        int k = read_key();
        if (k == 'q' || k == 0x1b || k == -1) { raw_off(); exit(EXIT_FAILURE); }   /* cancelled */
        if (k == '\n' || k == '\r') break;
        if ((k == KEY_UP || k == 'k') && sel > 0) sel--;
        if ((k == KEY_DOWN || k == 'j') && sel + 1 < c->n) sel++;
    }
    raw_off();
    printf("%ld\n", c->v[sel].id);                               /* the result goes to stdout */
}

The reader was tested on macOS by driving the program through a pseudo-terminal: Down, Down, Enter printed 3; j, k, Enter printed 1; q and a lone Esc cancelled with status 1; and a SIGINT while the menu was open ended the process by that signal with the terminal’s ICANON and ECHO flags restored to their original values.

Pitfalls specific to this reader:

  • Ctrl+Z (SIGTSTP) is not handled: the shell stops the program, and a tool that wants the terminal back in cooked mode while it is stopped installs SIGTSTP and SIGCONT handlers, as described under job control on Terminals, TTYs & Pseudo-Terminals.

  • Never read the keys with getchar or fgets after switching modes. The stdio buffer may already hold bytes read in canonical mode, and read(2) would not see them. shelf uses read throughout.

  • A resize does not interrupt read; a full-screen program also handles SIGWINCH (see Terminals, TTYs & Pseudo-Terminals).

  • The decoding is minimal: no Home, End, function keys, modifiers or mouse. Real applications use a library (see C & C++ TUIs: ncurses, PDCurses & FTXUI) or a decoder driven by the terminal’s terminfo entry.

  • Raw mode makes sense only on a terminal. shelf pick checks isatty on standard input and standard error and fails with status 64 otherwise, instead of failing inside tcgetattr.

Windows has no termios. The equivalent is the console input mode flags ENABLE_LINE_INPUT, ENABLE_ECHO_INPUT and ENABLE_PROCESSED_INPUT set with SetConsoleMode, plus ENABLE_VIRTUAL_TERMINAL_INPUT to receive arrow keys as the same escape sequences as above; the CRT also has _getch and _getwch in <conio.h>. These are covered in the console and terminals pages (see Windows Console & Processes).

Source: POSIX — tcsetattr, POSIX — termios.h and POSIX — poll. The escape sequences are defined by ECMA-48 and listed in XTerm Control Sequences. Compiled and run on macOS.

Windows: wmain, UTF-8 and the missing pieces

A C command-line program on Windows uses the same main model, with five differences that matter.

Entry point and text encoding

Approach How Consequence

main (ANSI)

Arguments converted to the active ANSI code page

Characters outside the code page become ?; the program cannot see them at all.

wmain

Arguments as UTF-16 (wchar_t *argv[])

Exact text. The program works in UTF-16 or converts to UTF-8 with WideCharToMultiByte(CP_UTF8, ...) at the boundary.

main with the activeCodePage manifest set to UTF-8

The ANSI code page of the process becomes UTF-8 (Windows 10 version 1903 or later)

The narrow main and the CRT narrow functions see UTF-8; the rest of the program can stay the same as on Linux.

The manifest and the console code pages are on Windows Console & Processes. For output, _setmode(_fileno(stdout), _O_U8TEXT) puts the stream in UTF-8 mode, and the documentation is explicit about the catch: Unicode modes are meant for the wide printing functions (wprintf, fwprintf), and using a narrow function such as printf on such a stream triggers an assertion. Call _setmode before any I/O on the stream (flush first if anything was already written), and print with %ls for wide strings. A short wmain program, compiled with MSVC’s /utf-8 option, shows all of this together:

/* shelf_win.c -- Windows entry point: wide arguments, UTF-8 console output. */
#include <fcntl.h>
#include <io.h>
#include <stdio.h>
#include <stdlib.h>
#include <wchar.h>
#include <windows.h>

/* _isatty is true for any character device, NUL included; GetConsoleMode is true only for a console. */
static int is_console(FILE *f)
{
    DWORD mode;
    HANDLE h = (HANDLE)_get_osfhandle(_fileno(f));
    return h != INVALID_HANDLE_VALUE && GetConsoleMode(h, &mode);
}

int wmain(int argc, wchar_t *argv[])
{
    /* After these calls only the wide printing functions may be used on the two streams. */
    if (_setmode(_fileno(stdout), _O_U8TEXT) == -1 || _setmode(_fileno(stderr), _O_U8TEXT) == -1) {
        return 74;
    }

    wchar_t *home = NULL;
    size_t len = 0;
    if (_wdupenv_s(&home, &len, L"SHELF_HOME") != 0) {      /* allocates; the caller frees */
        fwprintf(stderr, L"shelf: out of memory\n");
        return 70;
    }
    fwprintf(stdout, L"argc = %d, SHELF_HOME = %ls\n", argc, home != NULL ? home : L"(unset)");
    free(home);

    for (int i = 1; i < argc; i++) {
        fwprintf(stdout, L"argv[%d] = %ls\n", i, argv[i]);
    }
    fwprintf(stdout, L"stdout is %ls\n", is_console(stdout) ? L"a console" : L"a file or a pipe");

    if (fflush(stdout) != 0 || ferror(stdout)) {
        return 74;
    }
    return 0;
}

Source: Microsoft — main function and command-line arguments, Microsoft — _setmode, Microsoft — _dupenv_s, _wdupenv_s, Microsoft — _get_osfhandle and Microsoft — GetConsoleMode. This program was not compiled or run: no Windows toolchain was available. The isatty replacement in it follows from the documentation of _isatty, which is true for any character device (a console, a printer, a serial port, and the NUL device), so a program that wants to know about a console uses GetConsoleMode as above.

Arguments, wildcards and quoting

The Windows command line is one string; the program splits it, and the C runtime does it by the rules documented in "Parsing C command-line arguments". The shell does not expand wildcards, so shelf import *.txt hands the program the literal *.txt unless the program was linked with setargv.obj (wsetargv.obj for wmain), which enables expansion inside the runtime, or expands the pattern itself with FindFirstFile. Programs ported from UNIX that rely on the shell’s globbing must do one of these. The details, with CommandLineToArgvW and the quoting of cmd.exe and PowerShell, are on Windows Console & Processes.

Option parsing without getopt

The MSVC runtime has no getopt, no getopt_long, no argp, no <err.h> and no <sysexits.h>. The options are:

Option Notes

Vendor a getopt_long implementation

Small source files with permissive licences exist (the BSD libc one, gnulib’s getopt-gnu module); check the licence that fits the project, and the behaviour of optind resets, which differs between implementations.

Build with MinGW-w64, Cygwin or MSYS2

These toolchains bring their own C library functions, including getopt_long, at the price of a different runtime and (for Cygwin and MSYS2) a POSIX-emulation layer.

Write a small hand-made loop

Reasonable when the tool follows Windows spellings (/v, -Verbose), as long as it also accepts --.

Use C++ and CLI11 or cxxopts

Header-only parsers that behave the same on every platform; see Command-Line Programs in C++.

Other differences to plan for

  • The console subsystem. A program that should run in a console links with /SUBSYSTEM:CONSOLE (the default for a main or wmain program); a GUI-subsystem program has no console unless it asks for one.

  • Line endings and Ctrl+Z, described under standard input above.

  • Signals and Ctrl+C as described above: use SetConsoleCtrlHandler.

  • Terminal escape sequences work only after enabling ENABLE_VIRTUAL_TERMINAL_PROCESSING on the output handle; a shelf that colours its output enables it first and checks the result of SetConsoleMode.

  • The runtime choice (/MT static or /MD DLL) decides whether the binary needs a redistributable runtime; see Native Binaries & Cross-Compilation.

Source: Microsoft — Parsing C command-line arguments, Microsoft — /SUBSYSTEM and Microsoft — /utf-8. The statement about MinGW-w64, Cygwin and MSYS2 shipping getopt_long describes those toolchains' usual libraries and was not tested here.

Building with CMake and Meson

For a single file the build command is the compiler line. This is what was run to build and test shelf on macOS; on Linux add -D_FORTIFY_SOURCE=2 with optimisation if the distribution does not enable it by default:

$ cc -std=c11 -Wall -Wextra -Wpedantic -Wshadow -Wconversion -O2 -o shelf src/shelf.c
$ cc -std=c11 -g -fsanitize=address,undefined -o shelf_asan src/shelf.c   # a second build for testing

Anything bigger, or anything that must also build on Windows, uses a build system. The two common ones for C are CMake and Meson. The C Reference page Build and Tooling introduces both; the files below add what a command-line program needs: asking the C library what it provides instead of testing for an operating system, building a different source on Windows, installing the binary and running smoke tests.

cmake_minimum_required(VERSION 3.21)
project(shelf VERSION 0.3.0 LANGUAGES C)

set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_C_EXTENSIONS OFF)

# Ask the C library what it offers instead of testing for a platform.
include(CheckSymbolExists)
list(APPEND CMAKE_REQUIRED_DEFINITIONS -D_GNU_SOURCE)
check_symbol_exists(secure_getenv stdlib.h HAVE_SECURE_GETENV)
check_symbol_exists(argp_parse argp.h HAVE_ARGP)
list(REMOVE_ITEM CMAKE_REQUIRED_DEFINITIONS -D_GNU_SOURCE)

if(WIN32)
  add_executable(shelf src/shelf_win.c)           # wmain; UTF-8 console output
else()
  add_executable(shelf src/shelf.c)
endif()

target_compile_options(shelf PRIVATE
  $<$<C_COMPILER_ID:GNU,Clang,AppleClang>:-Wall;-Wextra;-Wpedantic;-Wformat=2>
  $<$<C_COMPILER_ID:MSVC>:/W4;/utf-8>)
if(HAVE_SECURE_GETENV)
  target_compile_definitions(shelf PRIVATE HAVE_SECURE_GETENV)
endif()

# The argp variant is only built where the C library has argp (glibc).
if(HAVE_ARGP)
  add_executable(shelf-argp src/shelf_argp.c)
endif()

install(TARGETS shelf RUNTIME DESTINATION bin)

include(CTest)
if(NOT WIN32)
  add_test(NAME version COMMAND shelf --version)
  set_tests_properties(version PROPERTIES PASS_REGULAR_EXPRESSION "^shelf 0\\.3\\.0")
  add_test(NAME no-command COMMAND shelf)
  set_tests_properties(no-command PROPERTIES WILL_FAIL TRUE)
endif()
$ cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
$ cmake --build build
$ ctest --test-dir build --output-on-failure
$ cmake --install build --prefix /usr/local
project('shelf', 'c',
  version : '0.3.0',
  meson_version : '>=1.0',
  default_options : ['c_std=c11', 'warning_level=3'])

cc = meson.get_compiler('c')
args = []
if cc.has_function('secure_getenv', prefix : '#define _GNU_SOURCE\n#include <stdlib.h>')
  args += '-DHAVE_SECURE_GETENV'
endif
if cc.get_id() == 'msvc'
  args += '/utf-8'
endif

if host_machine.system() == 'windows'
  src = 'src/shelf_win.c'
else
  src = 'src/shelf.c'
endif
shelf = executable('shelf', src, c_args : args, install : true)

# The argp variant is only built where the C library has argp (glibc).
if cc.has_header_symbol('argp.h', 'argp_parse', prefix : '#define _GNU_SOURCE')
  executable('shelf-argp', 'src/shelf_argp.c', install : true)
endif

if host_machine.system() != 'windows'
  test('version', shelf, args : ['--version'])
  test('no-command', shelf, should_fail : true)
endif
$ meson setup build
$ meson compile -C build
$ meson test -C build
$ meson install -C build

Source: Meson — Compiler properties (has_function, has_header_symbol), Meson — Reference manual: functions (executable, test) and Meson — Built-in options (c_std, warning_level). The CMake and Meson files were not run: neither tool was installed where the page was written, so they follow the documentation and have not been exercised.

Both systems share the same shape: declare facts (symbols, headers), choose sources per platform, set warnings per compiler, install and test. Neither replaces a decision about hardening flags, which belong to the packager:

Flag Purpose

-Wall -Wextra -Wpedantic

The baseline set of warnings (GCC, Clang); /W4 for MSVC.

-Wformat=2

Warns about printf-style calls whose format string is not a literal, the root of format-string vulnerabilities.

-fsanitize=address,undefined

AddressSanitizer and UndefinedBehaviorSanitizer for test builds. shelf ran its list, add, import, loan and return commands cleanly under them on macOS.

-D_FORTIFY_SOURCE=2 (or 3)

Compile-time and run-time checks for unsafe calls such as memcpy; needs optimisation; GCC and Clang on Linux.

-fstack-protector-strong, -fPIE -pie

Stack canaries and a position-independent executable (Linux distribution defaults).

-Wl,-z,relro,-z,now

Full RELRO for the GNU linker (Linux).

The sanitizers, Valgrind and static-analysis flags are described on Build and Tooling.

The complete shelf program

The listing below is the whole of src/shelf.c, the program that every excerpt above came from. It builds on Linux and macOS without changes; the Windows entry point is the separate file shown earlier, and the argp variant is a separate file.

/* shelf.c -- the Bookshelf command-line tool in C (POSIX: Linux and macOS). */
#define _GNU_SOURCE                /* glibc: getopt_long, secure_getenv (ignored elsewhere) */
#define _DARWIN_C_SOURCE           /* macOS: optreset */

#include <errno.h>
#include <getopt.h>
#include <limits.h>
#include <poll.h>
#include <signal.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <termios.h>
#include <unistd.h>

#if defined(__has_include)
#  if __has_include(<sysexits.h>)
#    include <sysexits.h>
#  endif
#endif
#ifndef EX_USAGE                    /* fallback when the header is missing */
#  define EX_USAGE 64
#  define EX_DATAERR 65
#  define EX_NOINPUT 66
#  define EX_SOFTWARE 70
#  define EX_CANTCREAT 73
#  define EX_IOERR 74
#endif

#define VERSION "0.3.0"

/* CMake or Meson defines HAVE_SECURE_GETENV when the C library provides it (glibc 2.17+). */
#ifdef HAVE_SECURE_GETENV
#  define env_get secure_getenv
#else
#  define env_get getenv
#endif

static const char *prog = "shelf";
static volatile sig_atomic_t got_signal = 0;
static struct termios saved_tio;
static int raw_active = 0;

/* ---- errors ------------------------------------------------------------ */

static void die(int status, const char *fmt, ...)
{
    va_list ap;
    fprintf(stderr, "%s: ", prog);
    va_start(ap, fmt);
    vfprintf(stderr, fmt, ap);
    va_end(ap);
    fputc('\n', stderr);
    exit(status);
}

static void die_errno(int status, const char *what, const char *path)
{
    int saved = errno;              /* fprintf may change errno */
    fprintf(stderr, "%s: %s '%s': %s\n", prog, what, path, strerror(saved));
    exit(status);
}

/* ---- terminal ---------------------------------------------------------- */

static void raw_off(void)
{
    if (raw_active) {
        tcsetattr(STDIN_FILENO, TCSAFLUSH, &saved_tio);
        raw_active = 0;
    }
}

static void raw_on(void)
{
    struct termios t;
    if (tcgetattr(STDIN_FILENO, &saved_tio) == -1) {
        die_errno(EX_IOERR, "tcgetattr", "stdin");
    }
    t = saved_tio;
    t.c_lflag &= ~(tcflag_t)(ICANON | ECHO);   /* keep ISIG: Ctrl+C still works */
    t.c_cc[VMIN] = 1;
    t.c_cc[VTIME] = 0;
    if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &t) == -1) {
        die_errno(EX_IOERR, "tcsetattr", "stdin");
    }
    raw_active = 1;
    atexit(raw_off);                           /* every normal exit path restores */
}

/* ---- signals ----------------------------------------------------------- */

static void on_signal(int sig)
{
    got_signal = sig;                          /* the only thing a handler does */
}

static void install_handlers(void)
{
    struct sigaction sa;
    memset(&sa, 0, sizeof sa);
    sa.sa_handler = on_signal;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;                           /* no SA_RESTART: blocking reads see EINTR */
    sigaction(SIGINT, &sa, NULL);
    sigaction(SIGTERM, &sa, NULL);
    sigaction(SIGHUP, &sa, NULL);

    sa.sa_handler = SIG_IGN;                   /* a closed pipe becomes EPIPE */
    sigaction(SIGPIPE, &sa, NULL);
}

/* Clean up, then die from the same signal so the shell sees status 128 + N. */
static void die_from_signal(void)
{
    int sig = (int)got_signal;
    raw_off();
    signal(sig, SIG_DFL);
    raise(sig);
    _exit(128 + sig);                          /* only reached if raise returned */
}

/* ---- the catalogue ----------------------------------------------------- */

struct book {
    long id;
    char *title, *author, *loaned_to;          /* loaned_to is "" when on the shelf */
};

struct catalogue {
    struct book *v;
    size_t n, cap;
};

static char *dup_or_die(const char *s)
{
    char *p = strdup(s);
    if (p == NULL) {
        die(EX_SOFTWARE, "out of memory");
    }
    return p;
}

static void push(struct catalogue *c, long id, const char *t, const char *a, const char *l)
{
    if (c->n == c->cap) {
        size_t cap = c->cap ? c->cap * 2 : 16;
        struct book *v = realloc(c->v, cap * sizeof *v);
        if (v == NULL) {
            die(EX_SOFTWARE, "out of memory");
        }
        c->v = v;
        c->cap = cap;
    }
    c->v[c->n++] = (struct book){ id, dup_or_die(t), dup_or_die(a), dup_or_die(l) };
}

static long parse_id(const char *s)
{
    char *end;
    errno = 0;
    long v = strtol(s, &end, 10);
    if (end == s || *end != '\0' || errno == ERANGE || v < 1) {
        die(EX_USAGE, "invalid book id '%s'", s);
    }
    return v;
}

static void load(struct catalogue *c, const char *path)
{
    FILE *f = fopen(path, "r");
    if (f == NULL) {
        if (errno == ENOENT) {
            return;                            /* a missing file is an empty shelf */
        }
        die_errno(EX_NOINPUT, "cannot open", path);
    }
    char *line = NULL;
    size_t cap = 0;
    ssize_t len;
    while ((len = getline(&line, &cap, f)) != -1) {
        if (len > 0 && line[len - 1] == '\n') {
            line[len - 1] = '\0';
        }
        char *id = strtok(line, "\t"), *t = strtok(NULL, "\t");
        char *a = strtok(NULL, "\t"), *l = strtok(NULL, "\t");
        if (id == NULL || t == NULL || a == NULL) {
            die(EX_DATAERR, "%s: malformed line", path);
        }
        push(c, parse_id(id), t, a, l ? l : "");
    }
    if (ferror(f)) {
        die_errno(EX_IOERR, "read error on", path);
    }
    free(line);
    fclose(f);
}

static void save(const struct catalogue *c, const char *path)
{
    size_t need = strlen(path) + 8;
    char *tmp_path = malloc(need);
    if (tmp_path == NULL) {
        die(EX_SOFTWARE, "out of memory");
    }
    snprintf(tmp_path, need, "%s.XXXXXX", path);
    int fd = mkstemp(tmp_path);                /* unique name, created with mode 0600 */
    FILE *f = fd == -1 ? NULL : fdopen(fd, "w");
    if (f == NULL) {
        if (fd != -1) {
            close(fd);
            unlink(tmp_path);
        }
        die_errno(EX_CANTCREAT, "cannot create", tmp_path);
    }
    for (size_t i = 0; i < c->n; i++) {
        const struct book *b = &c->v[i];
        fprintf(f, "%ld\t%s\t%s\t%s\n", b->id, b->title, b->author, b->loaned_to);
    }
    if (fflush(f) != 0 || ferror(f) || fclose(f) != 0) {   /* ENOSPC shows up here */
        unlink(tmp_path);
        die_errno(EX_IOERR, "write error on", tmp_path);
    }
    if (rename(tmp_path, path) == -1) {        /* atomic replacement */
        unlink(tmp_path);
        die_errno(EX_IOERR, "cannot rename onto", path);
    }
    free(tmp_path);
}

/* ---- options ----------------------------------------------------------- */

struct options {
    int verbose;
    int color;                                 /* 0 never, 1 always */
    const char *db;
};

static void usage(FILE *out)
{
    fprintf(out,
        "Usage: %s [OPTION]... COMMAND [ARG]...\n"
        "Manage a small bookshelf.\n\n"
        "Commands:\n"
        "  list [-l]            list books (-l: only loaned ones)\n"
        "  add TITLE AUTHOR     add a book\n"
        "  import [FILE]        add books from lines 'Title|Author' (stdin when omitted or -)\n"
        "  loan ID NAME         lend a book\n"
        "  return ID            take a book back\n"
        "  pick                 choose a book with the arrow keys (needs a terminal)\n\n"
        "Options:\n"
        "  -f, --file=FILE      catalogue file (default: $SHELF_HOME/books.tsv or ./books.tsv)\n"
        "  -v, --verbose        report what is being done on stderr\n"
        "      --color=WHEN     auto, always or never (default: auto)\n"
        "  -h, --help           show this help and exit\n"
        "  -V, --version        show the version and exit\n\n"
        "Exit status: 0 success, 64 usage error, 65 bad data, 66 missing input, 74 I/O error.\n",
        prog);
}

static int want_color(const char *when)
{
    if (strcmp(when, "always") == 0) return 1;
    if (strcmp(when, "never") == 0) return 0;
    if (strcmp(when, "auto") != 0) die(EX_USAGE, "invalid --color argument '%s'", when);
    const char *no_color = env_get("NO_COLOR"), *term = env_get("TERM");
    if (no_color != NULL && *no_color != '\0') return 0;   /* no-color.org: non-empty disables */
    if (term == NULL || strcmp(term, "dumb") == 0) return 0;
    return isatty(STDOUT_FILENO);
}

static void reset_getopt(void)
{
#if defined(__APPLE__) || defined(__FreeBSD__) || defined(__OpenBSD__) || defined(__NetBSD__)
    optreset = 1;
    optind = 1;
#else
    optind = 0;                                /* glibc: 0 also re-initialises internal state */
#endif
}

/* ---- commands ---------------------------------------------------------- */

static void cmd_list(const struct options *o, int argc, char **argv, struct catalogue *c)
{
    static const struct option lo[] = { { "loaned", no_argument, NULL, 'l' }, { 0 } };
    int only_loaned = 0, ch;
    reset_getopt();
    while ((ch = getopt_long(argc, argv, "+:l", lo, NULL)) != -1) {
        if (ch == 'l') only_loaned = 1;
        else die(EX_USAGE, "list: unknown option '%s'", argv[optind - 1]);
    }
    if (optind != argc) die(EX_USAGE, "list: unexpected argument '%s'", argv[optind]);
    printf(o->color ? "\x1b[1m%4s  %-32s %-20s %s\x1b[0m\n" : "%4s  %-32s %-20s %s\n",
           "ID", "TITLE", "AUTHOR", "LOANED TO");
    for (size_t i = 0; i < c->n; i++) {
        const struct book *b = &c->v[i];
        if (only_loaned && *b->loaned_to == '\0') continue;
        printf("%4ld  %-32s %-20s %s\n", b->id, b->title, b->author, b->loaned_to);
    }
}

static long next_id(const struct catalogue *c)
{
    long m = 0;
    for (size_t i = 0; i < c->n; i++) if (c->v[i].id > m) m = c->v[i].id;
    return m + 1;
}

static int has_forbidden(const char *s)       /* tabs and newlines break the file format; ESC is an attack */
{
    for (; *s != '\0'; s++) {
        unsigned char ch = (unsigned char)*s;
        if (ch < 0x20 || ch == 0x7f) return 1;
    }
    return 0;
}

static void cmd_add(const struct options *o, int argc, char **argv, struct catalogue *c)
{
    if (argc != 3) die(EX_USAGE, "add: expected TITLE and AUTHOR");
    if (has_forbidden(argv[1]) || has_forbidden(argv[2])) die(EX_DATAERR, "add: control characters are not allowed");
    long id = next_id(c);
    push(c, id, argv[1], argv[2], "");
    save(c, o->db);
    if (o->verbose) fprintf(stderr, "%s: added book %ld\n", prog, id);
}

static void cmd_import(const struct options *o, int argc, char **argv, struct catalogue *c)
{
    if (argc > 2) die(EX_USAGE, "import: at most one FILE");
    FILE *in = stdin;
    const char *name = "standard input";
    if (argc == 2 && strcmp(argv[1], "-") != 0) {
        in = fopen(argv[1], "r");
        if (in == NULL) die_errno(EX_NOINPUT, "cannot open", argv[1]);
        name = argv[1];
    } else if (isatty(STDIN_FILENO)) {
        fprintf(stderr, "%s: reading from the terminal; finish with Ctrl+D\n", prog);
    }
    char *line = NULL;
    size_t cap = 0;
    ssize_t len;
    size_t added = 0;
    for (;;) {
        errno = 0;
        len = getline(&line, &cap, in);
        if (len == -1) {
            if (errno == EINTR && got_signal) die_from_signal();
            break;                             /* EOF or error: told apart below */
        }
        if (len > 0 && line[len - 1] == '\n') line[len - 1] = '\0';
        if (*line == '\0') continue;
        char *bar = strchr(line, '|');
        if (bar == NULL) die(EX_DATAERR, "%s: missing '|' in '%s'", name, line);
        *bar = '\0';
        if (has_forbidden(line) || has_forbidden(bar + 1)) die(EX_DATAERR, "%s: control characters are not allowed", name);
        push(c, next_id(c), line, bar + 1, "");
        added++;
    }
    if (ferror(in)) die_errno(EX_IOERR, "read error on", name);   /* EOF is not an error */
    free(line);
    if (in != stdin) fclose(in);
    save(c, o->db);
    if (o->verbose) fprintf(stderr, "%s: imported %zu book(s)\n", prog, added);
}

static struct book *find(struct catalogue *c, long id)
{
    for (size_t i = 0; i < c->n; i++) if (c->v[i].id == id) return &c->v[i];
    die(EX_DATAERR, "no book with id %ld", id);
    return NULL;
}

static void cmd_loan(const struct options *o, int argc, char **argv, struct catalogue *c)
{
    if (argc != 3) die(EX_USAGE, "loan: expected ID and NAME");
    struct book *b = find(c, parse_id(argv[1]));
    if (*b->loaned_to != '\0') die(EX_DATAERR, "book %ld is already loaned to %s", b->id, b->loaned_to);
    if (has_forbidden(argv[2])) die(EX_DATAERR, "loan: control characters are not allowed");
    free(b->loaned_to);
    b->loaned_to = dup_or_die(argv[2]);
    save(c, o->db);
}

static void cmd_return(const struct options *o, int argc, char **argv, struct catalogue *c)
{
    if (argc != 2) die(EX_USAGE, "return: expected ID");
    struct book *b = find(c, parse_id(argv[1]));
    free(b->loaned_to);
    b->loaned_to = dup_or_die("");
    save(c, o->db);
}

/* Key reader: returns a byte, or 1000 + n for the arrow keys, or -1 on EOF. */
enum { KEY_UP = 1001, KEY_DOWN = 1002 };

static int read_key(void)
{
    unsigned char ch;
    for (;;) {
        ssize_t n = read(STDIN_FILENO, &ch, 1);
        if (n == 1) break;
        if (n == 0) return -1;
        if (errno == EINTR) { if (got_signal) die_from_signal(); continue; }
        die_errno(EX_IOERR, "read error on", "terminal");
    }
    if (ch != 0x1b) return ch;
    struct pollfd p = { STDIN_FILENO, POLLIN, 0 };
    unsigned char seq[2];
    if (poll(&p, 1, 50) <= 0 || read(STDIN_FILENO, &seq[0], 1) != 1) return 0x1b;   /* a lone Esc */
    if (seq[0] != '[' && seq[0] != 'O') return 0x1b;
    if (poll(&p, 1, 50) <= 0 || read(STDIN_FILENO, &seq[1], 1) != 1) return 0x1b;
    return seq[1] == 'A' ? KEY_UP : seq[1] == 'B' ? KEY_DOWN : 0;
}

static void cmd_pick(const struct options *o, int argc, char **argv, struct catalogue *c)
{
    (void)o; (void)argv;
    if (argc != 1) die(EX_USAGE, "pick: takes no arguments");
    if (!isatty(STDIN_FILENO) || !isatty(STDERR_FILENO)) die(EX_USAGE, "pick needs a terminal on stdin and stderr");
    if (c->n == 0) die(EX_NOINPUT, "the shelf is empty");
    size_t sel = 0;
    raw_on();
    for (int first = 1;; first = 0) {
        if (!first) fprintf(stderr, "\x1b[%zuA", c->n);         /* back to the first row */
        for (size_t i = 0; i < c->n; i++) {
            fprintf(stderr, "\r\x1b[2K%s%4ld  %s\x1b[0m\n", i == sel ? "\x1b[7m" : "", c->v[i].id, c->v[i].title);
        }
        int k = read_key();
        if (k == 'q' || k == 0x1b || k == -1) { raw_off(); exit(EXIT_FAILURE); }   /* cancelled */
        if (k == '\n' || k == '\r') break;
        if ((k == KEY_UP || k == 'k') && sel > 0) sel--;
        if ((k == KEY_DOWN || k == 'j') && sel + 1 < c->n) sel++;
    }
    raw_off();
    printf("%ld\n", c->v[sel].id);                               /* the result goes to stdout */
}

/* ---- main -------------------------------------------------------------- */

int main(int argc, char *argv[])
{
    const char *slash = strrchr(argv[0], '/');
    prog = slash ? slash + 1 : argv[0];

    enum { OPT_COLOR = 256 };
    static const struct option long_opts[] = {
        { "file",    required_argument, NULL, 'f' },
        { "verbose", no_argument,       NULL, 'v' },
        { "color",   required_argument, NULL, OPT_COLOR },
        { "help",    no_argument,       NULL, 'h' },
        { "version", no_argument,       NULL, 'V' },
        { 0 }
    };

    struct options o = { 0, 0, NULL };
    const char *color_when = "auto";
    int ch;
    while ((ch = getopt_long(argc, argv, "+:f:vhV", long_opts, NULL)) != -1) {
        switch (ch) {
        case 'f': o.db = optarg; break;
        case 'v': o.verbose = 1; break;
        case OPT_COLOR: color_when = optarg; break;
        case 'h': usage(stdout); return EXIT_SUCCESS;
        case 'V': printf("%s %s\n", prog, VERSION); return EXIT_SUCCESS;
        case ':': die(EX_USAGE, "option '%s' needs an argument", argv[optind - 1]);
        default:  die(EX_USAGE, "unknown option '%s' (try --help)", argv[optind - 1]);
        }
    }
    argc -= optind;
    argv += optind;
    if (argc == 0) { usage(stderr); return EX_USAGE; }

    o.color = want_color(color_when);
    char path[PATH_MAX];
    if (o.db == NULL) {
        const char *home = env_get("SHELF_HOME");
        int n = snprintf(path, sizeof path, "%s%sbooks.tsv", home ? home : "", home ? "/" : "");
        if (n < 0 || (size_t)n >= sizeof path) die(EX_USAGE, "SHELF_HOME is too long");
        o.db = path;
    }
    install_handlers();

    struct catalogue c = { 0 };
    load(&c, o.db);

    const char *cmd = argv[0];
    if      (strcmp(cmd, "list")   == 0) cmd_list(&o, argc, argv, &c);
    else if (strcmp(cmd, "add")    == 0) cmd_add(&o, argc, argv, &c);
    else if (strcmp(cmd, "import") == 0) cmd_import(&o, argc, argv, &c);
    else if (strcmp(cmd, "loan")   == 0) cmd_loan(&o, argc, argv, &c);
    else if (strcmp(cmd, "return") == 0) cmd_return(&o, argc, argv, &c);
    else if (strcmp(cmd, "pick")   == 0) cmd_pick(&o, argc, argv, &c);
    else die(EX_USAGE, "unknown command '%s' (try --help)", cmd);

    if (fflush(stdout) != 0 || ferror(stdout)) {           /* `shelf list > /dev/full` */
        if (errno != EPIPE) {                              /* a closed pipe is the reader's choice */
            fprintf(stderr, "%s: write error: %s\n", prog, strerror(errno));
        }
        return EX_IOERR;
    }
    return EXIT_SUCCESS;
}

A final session with the compiled program, as run on macOS:

$ export SHELF_HOME=$PWD
$ shelf add "Dune" "Frank Herbert"
$ printf 'Emma|Jane Austen\nUlysses|James Joyce\n' | shelf -v import
shelf: imported 2 book(s)
$ shelf loan 2 Ana
$ shelf list
  ID  TITLE                            AUTHOR               LOANED TO
   1  Dune                             Frank Herbert
   2  Emma                             Jane Austen          Ana
   3  Ulysses                          James Joyce
$ shelf loan 2 Bob; echo "exit=$?"
shelf: book 2 is already loaned to Ana
exit=65
$ shelf return x; echo "exit=$?"
shelf: invalid book id 'x'
exit=64
$ shelf --color=sepia list; echo "exit=$?"
shelf: invalid --color argument 'sepia'
exit=64
$ printf 'Bad\033[2J|X\n' | shelf import; echo "exit=$?"
shelf: standard input: control characters are not allowed
exit=65

Source: the functions used are documented in the references below; the program itself is original.

Pitfalls and security notes

  • Never pass user data as a format string. printf(argv[1]) lets the caller read memory with %s and write it with %n. Always printf("%s", argv[1]); compile with -Wformat=2.

  • Check every conversion and every allocation. strtol with errno and endptr, not atoi; snprintf with its result compared to the buffer size (shelf rejects an over-long SHELF_HOME instead of silently truncating a path); realloc into a temporary pointer.

  • Do not let untrusted text reach the terminal as-is. A book title containing the byte 27 can clear the screen, move the cursor, change the window title or, on terminals that support it, change the clipboard. shelf rejects control characters when data enters the catalogue; a tool that displays text from elsewhere filters it on output. The escape sequences involved are on Escape Sequences & Terminal Capabilities.

  • Create files safely. A predictable temporary name in a shared directory lets another user pre-create a symbolic link there. Use mkstemp (or O_CREAT | O_EXCL), keep the temporary file in the destination directory so that rename is atomic, and decide the permissions on purpose: the file shelf writes is private (mode 0600) because mkstemp creates it that way.

  • Treat the environment and argv[0] as input. Validate paths from SHELF_HOME; use secure_getenv in anything that may run set-user-ID; never build a shell command from them for system or popen. Use posix_spawn or exec with an argument array (see POSIX Processes, Signals & I/O).

  • Put -- before file names that come from a variable. A name starting with - is read as an option otherwise.

  • Keep handlers tiny. Set a volatile sig_atomic_t, nothing else; restore the terminal in normal code.

  • Restore the terminal on every path and never leave it in raw mode when the program is interrupted or crashes.

  • Check the final flush (fflush, ferror) so that a full disk or a closed pipe produces the right status.

  • Do not assume char is UTF-8 and one column wide. Measuring text for layout needs wcwidth-style tables; padding with %-32s in printf counts bytes, so the columns of shelf list misalign for titles with accents or CJK characters. See Escape Sequences & Terminal Capabilities.

  • Do not rely on library messages. The text printed by getopt for an unknown option differs by platform; print your own.

What the official documentation does not provide

  • POSIX standardises getopt but not getopt_long, argp, <err.h> or <sysexits.h>; their behaviour is documented by glibc, the BSD manual pages and the Linux man-pages project, and the BSD page discourages sysexits.h for new code.

  • The glibc manual does not say how argp behaves on Windows, musl or macOS (where it is absent), and says little about mixing ARGP_IN_ORDER with subcommand options.

  • Microsoft documents wmain, _setmode and the Unicode modes, but gives no single recommended recipe for a UTF-8 command-line program in C; the combinations on this page (wmain with wide output, or the UTF-8 manifest with narrow functions) are assembled from separate documentation pages.

  • NO_COLOR and the other colour variables are community conventions without a standard, and there is no portable way to ask a terminal whether it supports colour beyond TERM and isatty.

  • Neither CMake nor Meson documents command-line-specific guidance; the build files follow their general feature-test and install documentation.