Source: handbook/ in the repository. Every code block compiles: CI runs ray check on each one.

Command-line tool

A command-line program is the simplest thing raylang ships: a binary under a megabyte that starts in milliseconds. This chapter builds notes, a notes tool for the terminal, and with it the rules of a good terminal citizen: arguments and options, piped input, output for people and for programs, and exit codes.

The complete project is in examples/apps/notes-cli, with its tests. The raylang blocks are copied from it and CI checks that they still are.

notes add "Shopping" --tag home < list.txt
notes list --tag home
notes search milk --json | jq '.[].title'
notes rm 3 --yes

1. The project

ray new notes-cli && cd notes-cli

No package is needed: everything it uses is in the standard library. The code is split into four modules:

Module What it does
flags.ray sorts the arguments into command, positionals and options
store.ray the notes, one Markdown file per note
output.ray what gets printed: table, colours and JSON
main.ray the commands and the exit codes

The notes are stored as text files any other tool can read:

# Shopping
tags: home, weekend

milk, bread

2. Arguments and options

args() returns the arguments as an array. raylang ships no options library, and a tool with a handful of commands does not need one: fifty lines sort them out.

/// A parsed command line: `notes add "Title" --tag work --json`.
pub struct Parsed {
    command: string,
    positional: [string],
    options: Map<string, [string]>,
}
/// Sorts `argv` into command, positionals and options. `value_options` are the options that
/// take a value (`--tag work` or `--tag=work`); any other `--name` is a switch. A bare `--`
/// ends the options: everything after it is positional.
pub fn parse(argv: [string], value_options: [string]) -> Result<Parsed, string> {
    var command = "";
    var positional: [string] = [];
    var options: Map<string, [string]> = Map.new();
    var only_positional = false;
    var i = 0;
    while (i < argv.len()) {
        let a = argv[i];
        i = i + 1;
        if (only_positional || !a.starts_with("--")) {
            if (command == "") {
                command = a;
            } else {
                positional.push(a);
            }
            continue;
        }
        if (a == "--") {
            only_positional = true;
            continue;
        }
        var name = a.substring(2, a.len());
        var values = options.get_or(name, []);
        match (name.index_of("=")) {
            // --tag=work
            Option.Some(eq) => {
                let value = name.substring(eq + 1, name.len());
                name = name.substring(0, eq);
                values = options.get_or(name, []);
                values.push(value);
            },
            Option.None => {
                if (value_options.contains(name)) {
                    // --tag work
                    if (i >= argv.len()) {
                        return Result.Err("option --" + name + " needs a value");
                    }
                    values.push(argv[i]);
                    i = i + 1;
                }
            },
        }
        options.insert(name, values);
    }
    Result.Ok(Parsed { command: command, positional: positional, options: options })
}

Three details users expect: --tag home and --tag=home mean the same, an option can be repeated, and a bare -- ends the options, so you can search for a text that starts with --.

A mistyped option must be an error, not silence:

/// Rejects options the command does not know, so a typo is an error and not a silent no-op.
pub fn only(p: Parsed, known: [string]) -> Result<int, string> {
    for (name, _) in p.options {
        if (!known.contains(name)) {
            return Result.Err("unknown option --" + name);
        }
    }
    Result.Ok(0)
}

3. Piped input

The body of a note arrives on standard input when it comes from a pipe or a file. term.is_tty(0) tells whether the input is a terminal: if it is, there is nothing to read, and the program must not sit waiting.

// Everything on stdin, when it is a pipe or a file. With a terminal on stdin there is nothing to
// read and the program must not sit waiting.
fn piped_input() -> string {
    if (term.is_tty(0)) {
        return "";
    }
    var lines: [string] = [];
    while (true) {
        match (input()) {
            Option.Some(line) => lines.push(line),
            Option.None => break,
        }
    }
    lines.join("\n")
}

input() returns one line, or None at the end of the input.

4. Output for people and for programs

Colours only make sense when a person is looking. The rule: the output is a terminal, and the user has not asked otherwise with NO_COLOR.

/// Whether to colour the output: a terminal on stdout, and the user has not opted out.
pub fn colours() -> bool {
    term.is_tty(1) && env("NO_COLOR").is_none()
}

fn paint(code: string, text: string, on: bool) -> string {
    if (on) { "\u{1B}[" + code + "m" + text + "\u{1B}[0m" } else { text }
}

When redirected to a file or another command, term.is_tty(1) is false and the output stays clean. The table adapts to the terminal width with term.size(), and uses term.width and term.fit, which count cells rather than characters: an emoji or a Chinese character takes two.

For another program to use the output, --json. The text is composed with a backtick string and each value goes in with ${…}:

/// The notes as a JSON array, for scripts.
pub fn as_json(notes: [Note]) -> string {
    var items: [string] = [];
    for n in notes {
        let tags = json.render_arr(json.list(n.tags));
        items.push(
            `{"id": ${n.id}, "title": ${quote(n.title)}, "tags": ${tags}, "body": ${quote(n.body)}}`
        );
    }
    "[" + items.join(", ") + "]"
}

5. Errors and exit codes

A script decides what to do from the exit code, so every ending has its own:

Code Meaning
0 done
1 what was asked for does not exist (a note, or a search with no results)
64 the command line is wrong
74 disk error

The code is the integer main returns. Errors travel as values of a type of their own, which tells the two kinds of failure apart:

// Why a command failed. The two kinds end differently: a wrong command line prints the help and
// exits 64; a disk error exits 74.
enum Failure {
    Usage(string),
    Io(string),
}

fn bad_usage(e: string) -> Failure {
    Failure.Usage(e)
}

fn io_error(e: string) -> Failure {
    Failure.Io(e)
}

Each command propagates with ?, and main decides the ending in one place:

fn main() -> int {
    let p = match (flags.parse(args(), ["tag"])) {
        Result.Ok(p) => p,
        Result.Err(e) => return usage(e),
    };
    if (flags.has(p, "help")) {
        print(HELP);
        return 0;
    }
    match (run(p, store.default_dir())) {
        Result.Ok(code) => code,
        Result.Err(Failure.Usage(e)) => usage(e),
        Result.Err(Failure.Io(e)) => {
            eprint("notes: " + e);
            IO_ERROR
        },
    }
}

Error messages go to standard error with eprint, so they do not mix with the data in a pipe. The search follows the grep convention: with no results, it exits with 1.

    if (p.command == "list" || p.command == "search") {
        flags.only(p, ["tag", "json"]).map_err(bad_usage)?;
        let text = if (p.command == "search") { p.positional.join(" ") } else { "" };
        if (p.command == "search" && text == "") {
            return Result.Err(Failure.Usage("search takes the text to look for"));
        }
        let tag = flags.values(p, "tag").join("");
        let found = store.filter(store.all(dir), tag, text);
        if (flags.has(p, "json")) {
            print(output.as_json(found));
        } else if (found.len() > 0) {
            print(output.table(found, output.colours()));
        }
        // Like grep: a search that finds nothing is exit code 1, so `notes search x && …` works.
        return Result.Ok(if (p.command == "search" && found.len() == 0) { NOT_FOUND } else { 0 });
    }

6. Asking before deleting

notes rm asks, unless --yes is given. Without a terminal there is nobody to ask: the answer is no, and the message suggests --yes. That way a script never hangs.

// Asks a yes/no question on the terminal. Without a terminal there is nobody to ask: the answer
// is no, and the caller tells the user about --yes.
fn confirm(question: string) -> bool {
    if (!term.is_tty(0)) {
        return false;
    }
    let _ = io.write(question + " [y/N] ");
    let _ = io.flush();
    match (input()) {
        Option.Some(answer) => answer.trim().to_lower() == "y",
        Option.None => false,
    }
}

io.write writes without a newline and io.flush makes the question appear before waiting for the answer. For a password, term.read_hidden reads without showing what is typed, and term.read_key reads key by key for interactive menus.

7. Tests

The tests cover the pieces without launching the program: the argument parser, the files and the output. Each one uses its own temporary folder.

ray test

8. The binary

ray build --native --release -o notes
./notes add "First note" < /dev/null

The notes binary is about 800 KB and depends on nothing installed. It starts in about 3 ms, measured on a MacBook Pro M3 Pro, so it can be called in a shell loop without anyone noticing. --target builds for another platform, and the Shipping chapter covers how to publish it.

Next step

LLMs and MCP: another terminal tool, this time an agent that talks to a model and uses tools.

<!-- sync: sha256:4e21b830496e -->