A rewrite of the Plan 9 rc shell in Goose for UNIX-like systems and with structured CLI args
README.md

rc2 #

A Plan 9 rc shell in Goose, with a POSIX adapter for macOS and Linux. The implementation works, but full compatibility remains incomplete.

The Plan 9 manual defines the language. ../rc supplies a secondary reference, not the language specification.

Build #

Requirements: Goose, its standard library, Clang, and Make. The runtime uses libc. It needs no shell or line-editor library.

make
./rc2 -c 'x=(one two); echo pre$x.c'
./rc2 script.rc argument
./rc2

If Goose cannot find its standard library, set GOOSE_STDLIB:

GOOSE_STDLIB=/path/to/goose/stdlib make

The Makefile also detects ../goose/stdlib. GOOSE and GOOSE_CC select the compiler and C backend.

Nix #

The flake uses the compiler from chiefnoah/goose. flake.lock pins Goose and its Nixpkgs input. No local Goose checkout is necessary.

nix build .#rc2
nix run .#rc2 -- -c 'echo hello'
nix flake check

The default package is rc2. Supported systems are aarch64-darwin, aarch64-linux, and x86_64-linux. The package uses the platform libc, not musl. It installs Bash, Fish, and Zsh completions and the rc2(1) manual. Build checks cover the Goose unit suite, shell expansion, external commands, and the installed executable. The reference comparisons and full POSIX suites remain separate because they require host paths and terminal support.

The flake exports nixosModules.rc2 and homeManagerModules.rc2. Both also have a default alias. Add the rc2 input to your configuration flake:

inputs.rc2.url = "github:chiefnoah/rc2";

For NixOS, import the module in your system configuration:

{ inputs, ... }: {
  imports = [ inputs.rc2.nixosModules.rc2 ];
  programs.rc2.enable = true;
}

For Home Manager, import the module in your home configuration:

{ inputs, ... }: {
  imports = [ inputs.rc2.homeManagerModules.rc2 ];
  programs.rc2.enable = true;
}

Pass inputs through NixOS specialArgs or Home Manager extraSpecialArgs for these examples. The modules install rc2 and enable the corresponding manual support. NixOS uses environment.systemPackages and documentation.man.enable. Home Manager uses home.packages and programs.man.enable. Run man rc2 after activation. No manual MANPATH change is necessary. programs.rc2.package selects another package. The default comes from this flake for the host system. The modules do not change your login shell or initialization files.

Shell command-line options #

rc2 --help
rc2 --version
rc2 -n script.rc
rc2 -m "$HOME/.rcrc"
rc2 --completions fish

-h and --help show the shell options. --version reports rc2 development until the project assigns a release version. These requests do not read initialization files, scripts, or standard input. -v retains its rc meaning: show input on standard error.

Option Action
-c command Run a command string. Remaining arguments become $*.
-i, -I Select interactive or noninteractive mode.
-l Read $home/.rcrc.
-m file Read an explicit initialization file.
-n Check syntax without execution.
-e Exit after a failed command, subject to rc conditional rules.
-x Show commands on standard error.
-v Show input on standard error.
-s Show failed command status.
-r Show parsed nodes for diagnostics.
--completions shell [script] Print completions for Fish, Bash, Zsh, or rc.
-- End shell options.

Short flags can share one argument, such as -nv. Options after a script path or the value of -c belong to that script or command. Without a script or -c, rc2 reads standard input. The legacy flags -d, -p, and -V remain accepted without effect.

Login shells read $home/.rcrc if the file exists. The -l flag or a leading - in the executable name selects login mode. An explicit -m file runs before .rcrc. Non-login interactive shells do not automatically read a startup file. rc2 does not read $home/lib/profile or fall back to it.

Features #

  • Lists, variables, indirection, subscripts, counts, and quoted expansion.
  • Explicit and implicit concatenation, with pairwise and distributed expansion.
  • Unicode patterns, complemented classes, and sorted path expansion.
  • Functions, temporary assignments, groups, loops, conditions, and switches.
  • Pipelines, background commands, subshells, and command substitution.
  • Process substitution, descriptor redirection, and here-documents.
  • Source files, eval, exec, cd, shift, wait, flag, and whatis.
  • Function export and restricted function import through the environment.
  • Signal functions and sigexit.
  • Interactive input, multiline commands, history, and tab completion.
  • UNIX job control, resource limits, umask, and nonlocal loop and function control.

Plan 9 patterns differ from POSIX patterns. In particular, * includes hidden files, except . and ... Backslash is literal, except before a newline. Single quotes use doubled quotes, not backslash escapes.

Script arguments #

Declare arguments with a literal args block:

args {
    name copy.rc
    about 'Copy the selected files'
    version '1.0'
    flag verbose -v --verbose 'Show details'
    option mode -m --mode (fast safe) default safe 'Select a mode'
    option count -n --count int default 1
    positional files multiple required 'Input files'
}
echo $mode $count $files

The block parses $* and assigns each result to its named variable. A flag produces an empty list when absent, or 1 when present. repeat permits repeated flags or options. multiple permits multiple positional values. required rejects an absent value. int requires a signed 64-bit integer. Defaults accept one literal word or a literal list. Variable names use letters, digits, and underscores. The first character cannot be a digit. status is reserved.

Quoted words supply descriptions. Lists supply value choices. The block rejects expansions, commands, redirections, and duplicate names or aliases. The parser accepts --name=value, short-option groups, and -- before positional values.

Subcommands #

command name { ... } declares a subcommand. Command blocks can contain nested commands and the same argument declarations. The parser binds the full selected command path to $subcommand as a list:

args {
    name remote-tool
    flag verbose -v --verbose

    command remote {
        about 'Manage remotes'
        option host --host default local

        command add {
            option mode -m --mode (fast safe) default safe
            positional names multiple required 'Remote names'
        }
    }
}

switch ($"subcommand) {
case 'remote add'
    echo $host $mode $names
}

For remote-tool -v remote --host server add -mfast origin, $subcommand contains (remote add). $"subcommand joins the path into one word for the switch subject. Flags and options belong to their declaration level. Parent options must precede the selected child command. Each level can use about and version metadata. name sets the root program name. --help shows the selected level and its child commands. An absent child command also shows this help and exits successfully. Help and completion extraction never execute target commands, including commands before the args block.

No selector declaration is necessary. The parser reserves subcommand and rejects argument declarations with that variable name. Each level selects exactly one child. Nested command names belong only to their parent command. Argument variables must differ along a command path. Sibling commands can reuse variable names and option aliases. Only selected levels receive values. Variables from inactive commands contain empty lists. Each successful args declaration replaces $subcommand. A declaration without commands sets it to an empty list.

Parent positional arguments precede the child command. A child name marks the boundary unless an option consumes that word as its value. -- stops options at the current level. The selected child starts its own option parser. The same declaration supplies nested command completion for fish, bash, zsh, and rc2. After selection, the child parses the remaining arguments. A sibling name is positional data if the child accepts it.

The shell supplies --help, --version, and --completions from the declaration. --version requires version metadata. Argument errors stop the shell with exit code 2. For script files, metadata requests run no script commands or initialization files. Metadata extraction requires one top-level argument block. Without name, the program name uses the script basename. See examples/greet.rc for a complete script.

Wrapper arguments #

allow-unknown lets a wrapper parse its own prefix and forward the remaining arguments through $unparsed:

args {
    name nr
    allow-unknown
    flag x -x
    positional package required
}
nix run $package -- $unparsed

The wrapper can inspect $x before the call to nix. The parser stops after the last declared positional argument. It preserves all subsequent tokens, including known wrapper options, in $unparsed. It preserves their order, empty strings, whitespace, and spelling. It does not infer downstream option values.

Invocation Result
nr -x hello Set $x and select package hello. The forwarded list is empty.
nr hello -x --help Forward -x --help. Do not set $x or show wrapper help.
nr hello --verbose value Forward --verbose value.
nr hello -- --verbose Consume the optional separator and forward --verbose.
nr hello argument -- value Forward argument -- value, including the internal separator.
nr hello -- -- Forward one literal --.
nr -- -package Select the literal package -package.

Unknown options before the positional prefix ends remain errors. --help, --version, and --completions select wrapper metadata only before that boundary. Wrapper option values do not count toward the positional prefix. The parser consumes at most one optional -- immediately after the prefix.

This mode requires at least one positional argument. Every positional argument must use required without multiple. Only leaf commands can use this mode. Optional positional arguments and scopes with child commands are invalid. The parser reserves unparsed across the declaration tree when any command uses allow-unknown. Each successful parse replaces $unparsed, including an empty list or an inactive pass-through command.

Help shows the forwarded tail. Native and generated completions stop wrapper option suggestions after the prefix. Downstream command completion requires a custom hook. Declarations without allow-unknown retain strict option parsing and do not change $unparsed.

UNIX reference commands #

whatis [-sfvpb] [--] [name ...] selects signal handlers, functions, variables, executable paths, and builtins, respectively. Flags can form groups. Without flags, named lookup reports variables and the first matching function, builtin, or executable path. Without names, whatis lists variables and functions. Selectors restrict these listings to their types. whatis -s includes default and ignored signal dispositions. whatis -p requires a name and reports a diagnostic otherwise. Definitions use reusable rc syntax. A missing name sets failure status without skipping subsequent names.

. -i file [arg ...] reads a source file as interactive input. It prints prompts and recovers after command or syntax errors. $* contains the supplied arguments while the file runs. The command restores the previous arguments and interactive flag afterward. . -i /dev/tty reads commands directly from the terminal until end-of-file. Default interrupts and quits cancel the current source command and return to its next prompt. The command restores caller signal dispositions but preserves explicit trap changes from the source. Ordinary . file retains noninteractive error behavior. Source reads preserve partial input across signals and run pending traps before the read resumes.

Completion #

Generate completion scripts for rc2 itself:

./rc2 --completions fish
./rc2 --completions bash
./rc2 --completions zsh
./rc2 --completions rc
./rc2 --completions fish examples/greet.rc
./rc2 examples/greet.rc --completions bash

The rc2 editor reads a script's args declaration automatically during tab completion. This works for executable script paths and rc2 script.rc invocations. It runs no target commands. No completion installation or second schema is necessary inside rc2. Automatic extraction accepts regular files up to 4 MiB.

Other shells use the generated completion script after installation through their normal completion configuration. The rc output defines complete_rc2. Source that output to install an explicit hook. The editor calls complete_<basename> with the current prefix as $1 and the previous word as $2. The hook prints one candidate per line. The editor filters candidates by prefix.

schema.goose, cli.goose, and completion.goose supply the extension core:

  • Long options, --name=value, short options, and short-option groups.
  • Flags, values, repeated options, required options, and integer checks.
  • Enumerated values, positional values, and the -- separator.
  • Completion generation from the same option schema.

declaration.goose connects script declarations to this core, with defaults and generated help. Completion generation never runs a target script. Explicit hooks override automatic declaration extraction. Generated rc hooks contain private literal metadata for grouped short options. The editor reads this metadata without execution.

Interactive keys #

The example initialization file provides Fish-inspired shortcuts and fzf bindings.

Key Action
Tab Complete commands, paths, or a command hook
Up / Down Select a command from history
Left / Right Move the cursor
Ctrl-A / Ctrl-E Move to the start or end
Backspace Delete the previous grapheme cluster
Ctrl-U Clear the line
Ctrl-L Clear the screen and redraw the current input
Ctrl-C Cancel the command
Ctrl-D Delete the next character, or exit on an empty line

The editor supports wrapped lines, terminal resize, multiline history, and Unicode 18 grapheme movement. Completion supports quotes, nested command contexts, scalar variables, --name=value, and grouped short options. Declared positional choices also complete automatically, with option-value counts and repeated positional tails. Completion never runs substitutions from the input. Multivalue computed prefixes remain unsupported.

Configurable bindings #

bind key replace|insert|run function registers an editor hook. bind lists registrations. bind -e key removes a registration and restores the default key action. Keys use ctrl-a through ctrl-z, or alt- followed by one printable ASCII character. Custom bindings override default editor actions.

fn pick_text { bind_result='echo selected' }
bind ctrl-r replace pick_text
fn pick_path { bind_result=('file name' 'another file') }
bind ctrl-t insert pick_path
fn parent_dir { cd .. }
bind alt-c run parent_dir

The hook receives the current input as $1 and the cursor byte offset as $2. The hook runs in the current shell with normal terminal mode. Directory and variable changes persist. $bind_result starts empty for each call and returns its previous value after the call.

  • replace replaces the input with one result value and moves the cursor to its end.
  • insert inserts result values at the cursor with rc quoting. Inside quotes, it escapes apostrophes and joins values with spaces.
  • run ignores the result and preserves the input and cursor.

The editor does not evaluate returned text. Enter submits the input as usual. A nonzero hook status, empty result, or Ctrl-C preserves the input and cursor. One empty string clears the input with replace. Multiple values leave it unchanged. Insertion inside a comment or between doubled apostrophes leaves the input unchanged. The editor restores its terminal mode and redraws the input after the hook. Bindings are local to the shell process. Child shells do not inherit them.

UNIX process state #

umask [octal-mask] reports or changes the mask. newpgrp creates a process group. limit [-h] [resource [value]] reports or changes a soft limit, or a hard limit with -h. Values use bytes, seconds for cputime, or counts for descriptors, maxproc, and filelocks. Size suffixes are k, m, and g. Time suffixes are s, m, and h. unlimited selects the platform's unlimited value. Available resource names depend on the platform.

break exits the nearest loop. continue starts its next iteration. return [status ...] exits the current function and optionally changes $status.

With a controlling terminal, foreground jobs own the terminal. Ctrl-Z stops a job. Ctrl-C interrupts a job. jobs reports unconsumed jobs. fg [selector] resumes a foreground job. bg [selector] resumes a background job. The selector is %number from jobs, or a process-group ID. An absent selector chooses the latest job. wait also consumes completed background-job status. Shell exit sends hangup and continue signals to unfinished jobs.

Persistent history #

Set $history to one file path to persist interactive commands:

history=$home^'/.rc2_history'

An inherited $history value also loads previous commands at startup. An empty or absent value disables file writes. Noninteractive sessions never write history. The file contains rc source, including complete multiline commands. Reload parses records but never executes them. The shell creates files with mode 0600 and locks each complete append. The shell rejects symlinks, nonregular files, multiple links, foreign owners, and group or other permissions.

Tests #

Tangled CI runs Nix checks and both test suites for master pushes and pull requests. Version tags that match v* also publish source and Nix package archives after the tests pass.

The test tools require Babashka, Node.js, and Python 3 for the existing native-boundary suites. New terminal, job, history, and completion regressions use JavaScript with a small POSIX PTY driver. Fish, Bash, and Zsh provide their own completion syntax checks when available.

make check
make test
make test-debug
make bench

The suite covers shell behavior, parser errors, arguments, completion scripts, terminal input, memory reclamation, and signal functions. The debug build enables Goose range, overflow, and tag checks.

For Linux tests on a macOS host, build the disposable container:

docker build --build-context goose=../goose -f tests/Dockerfile -t rc2-linux-test .
docker run --rm --network none --mount type=bind,src="$PWD",dst=/source,readonly rc2-linux-test \
    sh -c 'cp -a /source/. /work && make -B .build/terminal-driver && make check test test-debug'

The container copies the source before the build. It does not modify the host repository. The forced driver build avoids reuse of a host executable from another platform. For Linux amd64 emulation, add --platform linux/amd64 to both Docker commands and select a separate image tag. Set the container environment RC2_MEMORY_TIMEOUT=300 for the slower emulated debug memory suite. This changes the time allowance, not the workload or memory limit.

Performance #

The startup benchmark compares named implementations with a native launcher, randomized rounds, and paired statistics.

make bench compares native rc2 with the installed rc on PATH. It measures whole-process wall time, with three warmups and twenty samples per workload. The runner reports the median, including process creation through Babashka. Small startup differences can reflect measurement noise.

The implementation uses indexed syntax pools, hashed variable lookup, cached status values, and posix_spawn for suitable external commands. The pipeline path avoids an extra interpreter process for simple external stages. No benchmark establishes that rc2 is the fastest shell. The measurements record the current baseline.

See COMPATIBILITY.md for known gaps and ARCHITECTURE.md for the code structure.