/
Search
EN

Command-line programs: arguments, environment, streams, and the terminal

A library is used by other programs. A command-line tool is used by a person at a terminal, and it has to get four things right: what the person typed, what they set in their shell, where the bytes go so that a pipeline can consume them, and how to behave at a terminal when nothing is piped. In kola each of these is a capability on sys: Args, Env, Stdout and Stdin (plus sys.stderr), and Tty. A tool that runs other programs adds Proc. This page shows how each is used, where each refuses, and how the cli pack turns raw argument bytes into a typed parse without closures.

sys.args: the invocation, as bytes

When a shell starts a program, it passes the words of the command line as a list of strings, traditionally called argv (argument vector). sys.args.all() returns that list as &heap [&heap [u8]]: a slice of byte slices that live for the whole program (heap is the region that is never freed). As in C, argv[0] is the program name and the caller's arguments start at index 1. Each element is a byte slice rather than a str, because nothing guarantees that arguments are valid UTF-8. When you need text, convert with utf8::to_str, which answers None for invalid input:

use std::stdio;
use std::text::utf8;

fn main(sys: Sys) -> u8 !{io} {
    let argv = sys.args.all();
    match utf8::to_str(argv[0]) {
        Some(name) => {
            let _ = stdio::write_all(&sys.stdout, &b"program: ");
            let _ = stdio::write_all(&sys.stdout, name.bytes);
            let _ = stdio::write_all(&sys.stdout, &b"\n");
        }
        None => {}
    }
    for i in 1..argv.len {
        let _ = stdio::write_all(&sys.stdout, &b"arg: ");
        let _ = stdio::write_all(&sys.stdout, argv[i]);
        let _ = stdio::write_all(&sys.stdout, &b"\n");
    }
    0
}

kola run stops parsing its own flags at --, so everything after it reaches the program unchanged, including tokens that look like flags:

$ kola run args-demo.kl -- deploy --env=prod --replicas 3
program: args-demo.kl
arg: deploy
arg: --env=prod
arg: --replicas
arg: 3

Reading the arguments is !{io} for the same reason reading stdin is: it is external input, and the effect row tells a reviewer so before they read the body. A native build reads the real process argv, and its argv[1..] agrees byte for byte with the interpreter's. argv[0] is whatever the operating system supplied.

A test has no invocation. sim.args.all() is always empty, so code whose behaviour depends on its arguments is exercised through kola run FILE -- ..., or by passing the argument slice into a function you can test directly.

sys.env: one variable, looked up by name

Every process also inherits a set of environment variables from its parent, name and value pairs such as HOME=/home/ana or PORT=8080, which shells and service managers use to pass settings. sys.env.get(b"HOME") answers Option[&heap [u8]] and is !{io}. It follows the same reasoning as Args (bytes, external, fixed at process start) with one extra restriction: it is lookup-only. You can ask for one variable by name, but you cannot list the environment. A capability that handed back the whole block would expose an unbounded amount of information with no name a reviewer could check. Asking for one name at a time is as auditable as any other lookup, and it is what configuration precedence needs: an explicit flag wins over an environment variable, which wins over a built-in default.

// A flag beats the environment, which beats a default.
use std::stdio;
use std::text::num;

// Looks for `--port <value>` in the raw arguments. A real program would
// use the `cli` pack instead of a hand-written scan.
fn flag_port(argv: &[&heap [u8]]) -> Option[i64] !{} {
    let mut i: usize = 1;
    while i < argv.len {
        if argv[i] == &b"--port" && i + 1 < argv.len {
            return num::parse_i64(argv[i + 1]);
        }
        i = i + 1;
    }
    None
}

fn resolve_port(sys: &Sys) -> i64 !{io} {
    match flag_port(sys.args.all()) {
        Some(p) => p,
        None => match sys.env.get(&b"PORT") {
            Some(v) => match num::parse_i64(v) {
                Some(p) => p,
                None => 8080i64,   // set, but not a number: use the default
            }
            None => 8080i64,
        }
    }
}

fn main(sys: Sys) -> u8 !{io, alloc(heap)} {
    let port = resolve_port(&sys);
    let mut buf: Vec[u8] = Vec::new();
    num::push_i64(&mut buf, port);
    let _ = stdio::write_all(&sys.stdout, &b"port: ");
    let _ = stdio::write_all(&sys.stdout, &buf[0..buf.len]);
    let _ = stdio::write_all(&sys.stdout, &b"\n");
    0
}
$ kola run env-precedence.kl
port: 8080

$ PORT=9090 kola run env-precedence.kl
port: 9090

$ PORT=9090 kola run env-precedence.kl -- --port 7000
port: 7000

Under kola test the environment is empty, even for variables the test runner itself has, such as PATH. A test that could see its runner's environment would pass or fail depending on who ran it. A freestanding build has no process and therefore no environment, so kola build --native --freestanding refuses sys.env.get by name and suggests reading the setting from a granted configuration directory (sys.fs.root(b"cfg")), taking it as a parameter, or building hosted. For settings that are really files, a granted root is the better tool anyway: the manifest names exactly which directory the program can read.

Output, diagnostics and talking

Every Unix process starts with three open streams: standard input, standard output and standard error, known by their file descriptors 0, 1 and 2 (a descriptor is the small integer a process uses to name an open file or stream). A shell connects them to the terminal by default, and the | and > operators reconnect them to another program or a file. In ls | grep x, the output of ls becomes the input of grep, so anything extra ls printed to stdout would end up in grep's input. kola separates three things a program may emit, and a command-line tool meets the distinction every day:

  • Output goes to sys.stdout: the bytes another program will parse. A stray debug line here does not clutter the output; it corrupts it for whatever is reading the pipe.

  • Diagnostics for the caller go to sys.stderr, file descriptor 2, in the program's own format (a compiler's file:line: message lines, for example). It has the same Stdout type, so stdio::write_all(&sys.stderr, ...) works.

  • Telemetry for the operator goes to log, which needs no capability. The operator chooses its sink (--log stderr, KOLA_LOG) and level.

Stdout and Stdin are separate capabilities because "may this code read the console" and "may it write to it" are different questions in an audit.

Two practical details. First, write may be short: a pipe with a full buffer accepts only part of what you offered, and that is normal. stdio::write_all loops until every byte is written or a real error occurs; use it rather than bare write. Second, sys.stdin.read_line(&mut buf) appends one line to buf without the trailing newline and answers Ok(true); Ok(false) means end of input, which is an ordinary answer, not an error. Errors from these operations are (IoErr, OsErr) pairs. Reading advances the stream, so the reader needs a mutable Stdin, and main is declared mut sys:

use std::stdio;
use std::log;

fn main(mut sys: Sys) -> u8 !{io, block, log} {
    let mut buf: Vec[u8] = Vec::new();
    let mut n: u64 = 0;
    loop {
        buf.clear();
        match sys.stdin.read_line(&mut buf) {
            Ok(true) => {
                n = n + 1;
                // This goes to the log sink and can never land in the
                // echoed lines on stdout.
                log::info("echoing one line");
                let _ = stdio::write_all(&sys.stdout, &buf[0..buf.len]);
                let _ = stdio::write_all(&sys.stdout, &b"\n");
            }
            Ok(false) => { break; }   // end of input
            Err(_) => { break; }
        }
    }
    log::info(f"done: {n} line(s)");
    0
}

With stdout and stderr captured separately, the split is visible:

$ printf 'alpha\nbeta\ngamma\n' | kola run stdio-echo.kl --log stderr >out.txt 2>err.txt

$ cat out.txt
alpha
beta
gamma

$ cat err.txt
[info] echoing one line
[info] echoing one line
[info] echoing one line
[info] done: 3 line(s)

Look at the two log calls. A plain string literal never interpolates, so the first is fixed text. The second uses f"..." to put n into the message. Writing log::info("done: {n} line(s)") would be refused, with a suggestion to add the f.

Under kola test, sim.stdin is at end of input immediately and sim.stdout is captured. That is enough to prove that a loop like the one above terminates on empty input, but not what it answers to real lines. A line protocol over stdin is tested by piping real bytes through kola run and kola run --native and comparing the outputs.

The terminal: sys.tty, RawMode and is_terminal

Normally a terminal works in cooked mode: it collects a whole line, lets the user edit it with backspace, echoes the typed characters, and only then hands the line to the program. Editors and password prompts need raw mode instead, where every key reaches the program as it is pressed and nothing is echoed. A program that switches to raw mode and exits without switching back leaves the user's shell unusable until they type reset.

sys.tty is the controlling terminal (/dev/tty), the terminal the user is sitting at, even when the program's standard streams have been redirected elsewhere. Every operation on it works regardless of how stdin, stdout or stderr are redirected:

  • tty.size() answers (rows, cols) as (u16, u16). It cannot fail; without a real terminal it reports 24 by 80.

  • tty.write(&bytes) writes a prompt for the human, such as Password: . It is not for diagnostics (those go to log) or output (that goes to stdout). A tool whose stdout carries machine-readable data has nowhere else to put a prompt, and stderr is not a reliable stand-in, because 2>log sends the prompt into a file and the program then waits for an answer nobody was asked for. A full-screen TUI still writes its escape sequences to sys.stdout.

  • tty.raw() answers Result[RawMode, (IoErr, OsErr)]. It switches the terminal to raw mode (no line buffering, no echo) so the program can read keys as they are typed.

RawMode is a linear resource with exactly one consuming operation, raw.restore(), which answers (). Leaving a shell in raw mode is therefore a compile error. While the session is live, reads go through it, raw.read(&mut buf, timeout_ms), which answers Ok(None) when the timeout passes (a terminal has no end of file). Writes go through it too, raw.write(&bytes); tty.write is refused while a session is live, because raw mode turned off output processing and a \n would arrive as a bare line feed. tty.raw() discards any input typed before the switch, so a keystroke or paste that arrived before your prompt cannot become the answer to it.

Because sys.tty ignores redirection, it cannot answer "is a person typing at me". That is a question about the program's own stdin, and sys.stdin.is_terminal() answers it: read a password from the pipe when it says false, and from the terminal when it says true.

use std::log;
use std::stdio;

fn main(sys: Sys) -> u8 !{io, block, log} {
    let sz = sys.tty.size();
    log::info(f"size: {sz.0}x{sz.1}");
    let piped = !sys.stdin.is_terminal();
    log::info(f"stdin is a pipe: {piped}");
    let _ = sys.tty.write(&b"Password: ");
    match sys.tty.raw() {
        Ok(raw) => {
            let mut raw = raw;
            let mut buf: Vec[u8] = Vec::new();
            match raw.read(&mut buf, 200u64) {
                Ok(Some(n)) => { log::info(f"read {n} byte(s)"); }
                Ok(None) => { log::info("no key within 200 ms"); }
                Err(_) => { log::info("read failed"); }
            }
            let _ = raw.write(&b"\r\n");
            raw.restore();
        }
        Err(_) => { log::info("no controlling terminal, raw mode refused"); }
    }
    0
}

The first run below has no controlling terminal at all, as in a CI job or a daemon. Every terminal operation fails with an error instead of writing somewhere nobody will see it. The second runs under a pseudo-terminal (a software terminal, here created by the script command) with stdin redirected from /dev/null: the prompt reaches the terminal, and raw mode succeeds even though stdin is not a terminal, because the session is on /dev/tty.

$ kola run tty-demo.kl --log stderr </dev/null
[info] size: 24x80
[info] stdin is a pipe: true
[info] no controlling terminal, raw mode refused

$ script -qec "kola run tty-demo.kl --log stderr </dev/null" /dev/null
[info] size: 24x80
[info] stdin is a pipe: true
Password: [info] no key within 200 ms

Now remove the restore:

use std::log;
use std::stdio;

fn main(sys: Sys) -> u8 !{io, block, log} {
    match sys.tty.raw() {
        Ok(raw) => {
            let mut raw = raw;
            let mut buf: Vec[u8] = Vec::new();
            let _ = raw.read(&mut buf, 200u64);
            // no raw.restore(): the terminal is left in raw mode
        }
        Err(_) => {}
    }
    0
}
error[card §2]: resource `raw` is not consumed on this path
  --> tty-forgot-restore.kl:7:13
   |
 7 |             let mut raw = raw;
   |             ^^^
   = help: no implicit destructors: move it on, return it, destructure it, or pass it to a consuming fn

Linearity covers normal paths only. If the process is killed by SIGINT or SIGTERM while in raw mode, the runtime restores the terminal from a saved copy. A RawMode may not be used inside a domain block; hold the session outside it and put only the computation inside, so that a trap leaves the terminal restorable.

Under kola test the terminal is a deterministic stand-in: size() is (24, 80), raw() succeeds with a session that does nothing, read() times out immediately, and is_terminal() is false. That is enough to check the whole flow, including the obligation to restore.

use std::stdio;

test sim_has_no_invocation (mut sim: Sim) {
    assert sim.args.all().len == 0usize;
    assert sim.env.get(b"PATH") == None;
    assert !sim.stdin.is_terminal();
    let mut buf: Vec[u8] = Vec::new();
    let r = sim.stdin.read_line(&mut buf);
    assert r == Ok(false);
    let sz = sim.tty.size();
    assert sz.0 == 24 && sz.1 == 80;
}

The cli pack: flags without closures

A hand-written scan is fine for one flag. It stops scaling when a tool grows subcommands, --help, required flags or repeatable flags, which is what the cli pack is for. Its design follows from the fact that kola has no closures. A framework that attached a run function to every command would force one set of capabilities and effects on the whole command tree, and a list subcommand should not need the authority an import subcommand does. So cli only parses. A pure function with no capabilities reads the argument bytes and returns which command matched and the typed flag values. The application writes the match and spends, in each branch, only the capabilities that branch needs. In clap's terms, you get ArgMatches and write the dispatch yourself.

A flag declared multi collects every occurrence (--peer a --peer b becomes a list of two, read with matches::get_list), and it may also be required. This tool uses one required, repeatable flag. It is a pack: its kola.pack lists cli under [deps] and declares [caps] allowed = ["Sys", "Args", "Stdout", "log"].

use std::stdio;
use std::log;
use std::text::utf8;
use cli::spec;
use cli::parse;
use cli::parse::Outcome;
use cli::matches;

fn build[r]() -> spec::Command[r] !{alloc(r)} {
    let mut root = spec::command("deploy", "deploy to a set of peers", "1.0.0");
    spec::flag_str(&mut root, "peer", 112u8, true, "a peer address (repeatable)");
    spec::multi(&mut root, "peer");
    root
}

fn main(sys: Sys) -> u8 !{io, log, alloc(heap)} {
    let mut code = 0u8;
    region r {
        let root = build();
        let argv = sys.args.all();
        let out: &r mut Vec[u8] = alloc in r mut Vec::new();
        match parse::parse(&root, &argv[1..argv.len], out) {
            Ok(o) => match o {
                Outcome::Run(m) => {
                    let peers: &r mut Vec[&heap [u8]] = alloc in r mut Vec::new();
                    matches::get_list(&m, "peer", peers);
                    log::info(f"{peers.len} peer(s) declared");
                    for i in 0..peers.len {
                        let _ = stdio::write_all(&sys.stdout, &b"peer: ");
                        let _ = stdio::write_all(&sys.stdout, peers[i]);
                        let _ = stdio::write_all(&sys.stdout, &b"\n");
                    }
                }
                Outcome::Help => { let _ = stdio::write_all(&sys.stdout, &out[0..out.len]); }
                Outcome::Version => { let _ = stdio::write_all(&sys.stdout, &out[0..out.len]); }
            }
            Err(e) => {
                // A parse error is a diagnostic, not output.
                let rendered: &r mut Vec[u8] = alloc in r mut Vec::new();
                parse::render_error(&e, rendered);
                match utf8::to_str(&rendered[0..rendered.len]) {
                    Some(s) => { log::error(s); }
                    None => { log::error("parse error"); }
                }
                code = 2u8;
            }
        }
    }
    code
}
$ kola run src/main.kl --log stderr -- --peer 10.0.0.1:9000 --peer 10.0.0.2:9000
[info] 2 peer(s) declared
peer: 10.0.0.1:9000
peer: 10.0.0.2:9000

$ kola run src/main.kl --log stderr
[error] error: missing required peer
$ echo $?
2

$ kola run src/main.kl -- --help
deploy to a set of peers

USAGE:
    deploy [OPTIONS]

OPTIONS:
    -p, --peer <PEER>  a peer address (repeatable) [repeatable]
    -h, --help         print help

cli itself declares no capabilities, but the program that uses it reaches Args and Stdout in its own main, so its manifest has to name them; kola audit checks that. A command-line tool's manifest ends up listing the capabilities this page covers and nothing else. Packages describes the mechanism.

sys.proc: running another program by granted name

A tool that runs git, ffmpeg or ssh uses Proc, and it works like a granted filesystem root: the program never spells a path. The operator binds a name to a binary with kola run --proc-bin ffmpeg=/usr/bin/ffmpeg, or with KOLA_PROC_BIN_ffmpeg in the environment, which a deployed binary reads without kola present. [caps] proc_bins = ["ffmpeg"] in the manifest tells an auditor exactly which programs the pack may run.

sys.proc.spawn(name, argv, env, stdio, slots) answers Result[Child, (ProcErr, OsErr)]. Several details are not guessable:

  • argv is the argument list without the program name, which the runtime supplies. If you forward your own sys.args.all() whole, your program's path becomes the child's first argument.

  • env is explicit and may be empty. Nothing is inherited, neither the environment nor the signal state. A child that needs a variable gets it because the parent read it through its own Env and passed it on. An environment key naming a grant prefix (KOLA_FS_ROOT_*, KOLA_PROC_BIN_* and the like) traps, because grants come from the operator, never from a parent.

  • Elements of argv and env may live in any region, since spawn copies them before it returns.

  • The stdio disposition (Stdio::Inherit, Stdio::Piped or Stdio::Null) has no default, because the obvious default deadlocks: a pipe nobody reads blocks the child once its buffer fills, and the parent's wait never returns.

  • slots is an array of SpawnSlot values (Closed, File, Dev, Listen, Netlink), written [] when the child gets none. slots[i] lands on the child's descriptor 3 + i, and a Closed entry leaves that descriptor closed without shifting the later ones. Every resource passed in a slot is consumed, even when the spawn fails. No other descriptor of the parent's reaches the child.

use std::log;
use std::proc;
use std::stdio;

// The binary is a granted name: `kola run --proc-bin echo=/bin/echo`.
fn main(sys: Sys) -> u8 !{io, block, log} {
    let name = "world".bytes;
    match sys.proc.spawn(b"echo", &[&b"hello", name], &[], Stdio::Piped, []) {
        Ok(child) => {
            let mut child = child;
            let mut out = child.take_stdout().unwrap();
            let mut buf = [0u8; 64];
            let n = match out.read(&mut buf) { Ok(n) => n, Err(_) => 0usize };
            let _c = out.close();
            match child.wait() {
                ExitStatus::Code(c) => { log::info(f"child wrote {n} bytes, exited {c}"); c }
                ExitStatus::Signaled(_) => { log::info("child was signalled"); 1 }
            }
        }
        Err((ProcErr::NotGranted, _)) => { log::error("no `echo` grant"); 2 }
        Err(_) => { log::error("echo did not start"); 3 }
    }
}
$ kola run child.kl --proc-bin echo=/bin/echo --log stderr
[info] child wrote 12 bytes, exited 0

$ kola run child.kl --log stderr
[error] no `echo` grant
$ echo $?
2

use std::stdio; is needed even though the program never names IoErr: the child's pipe operations answer (IoErr, OsErr), and the compiler asks for the module that defines the type.

On Unix, a finished child process stays in the process table as a zombie until its parent collects its exit status, which is called reaping it. Child is a linear handle. wait() and kill() consume it and reap the child, so an unreaped child is a compile error rather than a zombie. try_wait(&mut Child) and signal(&mut Child, Signal) do not consume it, which is what makes a stop sequence (send Term, wait a grace period, then kill) possible to write. With Stdio::Piped, take_stdin(), take_stdout() and take_stderr() each answer Some exactly once. Each half is linear and must be closed. Closing the write half matters: a child that reads its stdin to end of file never sees the end while your copy is open. A read of 0 bytes from a ChildOut is end of stream.

A signal is a small asynchronous notification the operating system delivers to a process: SIGTERM asks it to stop, SIGKILL stops it unconditionally, SIGINT is what Ctrl-C sends. ExitStatus is Code(u8) or Signaled(Signal). Signal is a name (Hup, Int, Quit, Term, Kill, Usr1, Usr2), plus Other(i32) for a received signal with no name, never a raw number. Signal numbers differ between systems (10 is SIGBUS on macOS and SIGUSR1 on Linux x86-64), so a number in a supervisor's log can silently name the wrong outcome.

A grant can pin the binary's content with --proc-bin name=/path:sha256=<hex>; a binary that no longer matches answers PinMismatch and nothing runs. There is no simulator for Proc: under kola test a granted spawn really runs. For a program that wants to handle SIGINT or SIGTERM itself, sys.proc.interrupt_on(Signal::Int) installs a handler and returns a linear InterruptGuard that must be restored; see the Processes section of STD-CARD.

A child also has a side of this. sys.inherited.take(i) answers the SpawnSlot its parent put at index i, or None. A second take of the same index is None. This transfers an existing descriptor; it never mints a new grant.

Exit codes and traps

Every process ends with an exit code, a small number its parent can read: by convention 0 means success and anything else describes a failure (a shell shows it as $?). In kola there is no exit() call and no global exit status. The u8 that main returns is the process exit code. It is eight bits wide because that is all a parent can observe: main returns u8 or nothing, -> i32 is refused, and a computed code narrows explicitly with narrow[u8](n), so your source decides what happens to a value above 255.

use std::stdio;

fn main(sys: Sys) -> u8 !{io} {
    let argv = sys.args.all();
    if argv.len < 2usize {
        let _ = stdio::write_all(&sys.stderr, &b"no target given\n");
        return 1;
    }
    let _ = stdio::write_all(&sys.stdout, &b"deploying: ");
    let _ = stdio::write_all(&sys.stdout, argv[1]);
    let _ = stdio::write_all(&sys.stdout, &b"\n");
    0
}
$ kola run exit-code.kl
no target given
$ echo $?
1

$ kola run exit-code.kl -- prod-east
deploying: prod-east
$ echo $?
0

A returned code is something the program says on purpose. A trap (an out-of-bounds index, a failed req, an explicit trap("msg")) is an unhandled halt, covered in Contracts, traps and tests. On both executors a trap exits with status 101, so a shell script or supervisor can tell "this program rejected its input" from "this program has a bug" without parsing output:

use std::stdio;

// argv[5] traps unless the caller passed at least five arguments.
fn main(sys: Sys) -> u8 !{io} {
    let argv = sys.args.all();
    let _ = stdio::write_all(&sys.stdout, argv[5]);
    0
}
$ kola run exit-trap.kl
trap: error[card §8]: index 5 out of bounds (len 1)
  --> exit-trap.kl:6:43
   |
 6 |     let _ = stdio::write_all(&sys.stdout, argv[5]);
   |                                           ^^^^^^^
   = help: indexing carries a builtin req; an upstream `if i < xs.len` guard discharges it statically
$ echo $?
101

$ kola run --native exit-trap.kl
trap: error[card §8]: index 5 out of bounds (len 1)
  --> exit-trap.kl:6
$ echo $?
101

Both executors report the same message, card section and file:line. A native binary does not carry the column, the source excerpt or the help text.