fshell

A Unix shell in Rust with sh syntax and typed pipelines. Existing POSIX scripts still run.

Why another shell

Normal shells pass text between commands. Data that starts out structured in the kernel gets formatted into text, and then the next command parses it back with awk, sed, grep, and cut. That parsing breaks whenever the output format changes.

Nushell fixes this with typed pipelines, but it uses its own syntax and isn’t POSIX compatible. fshell keeps sh syntax and passes typed records between its native commands.

Structured pipelines

Native commands pass typed values to each other. You can filter process records and pick fields right in the pipeline:

ps | filter cpu > 50.0 | map pid command | limit 5

This lists the PID and command of up to five processes using more than 50% CPU.

filter is a keyword that fshell parses itself, so the > in its condition is a comparison, not a redirect. Everywhere else > redirects like it does in any sh.

The common stages are filter, map, sort, grep, count, and limit, with formatters like @table, @json, @csv, and @text. Builtins include a git-aware ls, ps with typed CPU and memory fields, a recursive file scanner called files, extract for common archive formats, and string, json, csv, diff, vault, serve, and explain.

ls | filter type == "file" | sort size desc | map name size | limit 5 | @table

Interactive use

Tab completion is grouped by kind, with parameter hints and suggestions from history and the current directory. History is stored in SQLite and you can search it.

Aliases expand inline as you type, and backspace collapses them again. The git prompt reads git’s index directly. Old prompts collapse to one line after you run a command. config edit opens a settings screen for options, themes, and aliases.

Scripts and POSIX

Scripts are .fsh files with let bindings, typed functions, match, try and catch, and heredocs. You can put POSIX shell in the same file when you need it. For example, a release script:

let version = "v0.1.0"
let dist = "./dist"

fn build {
  echo "building {version}..."
  cargo build --release
}

sh {
  if ! command -v cargo >/dev/null 2>&1; then
    echo "cargo is missing" >&2
    exit 1
  fi
  if [ ! -d "$dist" ]; then
    mkdir -p "$dist"
  fi
}

build

sh {
  tar -czf "$dist/release.tar.gz" -C "target/release" "fsh"
  shasum -a 256 "$dist/release.tar.gz" > "$dist/checksum.txt"
}

ls "target/release" | filter name == "fsh" | map name size | @table
ls $dist | @table

The sh blocks hold portable shell code: the cargo check, mkdir -p, tar, shasum, and redirections. The fsh parts define the build function and use ls pipelines that read name and size as fields.

sh blocks run in the same process and environment as the rest of the script, so variables and the working directory are shared in both directions. posix and bash blocks work the same way, as do sourcing a bash script such as a virtualenv activate and running a file with an sh or bash shebang.

Routing is decided per stage within a pipeline. Each stage is resolved in this order: aliases, user functions, builtins, then external commands on PATH. If a stage has the same name as a native keyword but takes a flag, it runs the external binary instead. This runs the native sort:

ls | sort size desc

This runs the system sort:

ls | sort -n -k 2

Lines that don’t parse as fsh but look like POSIX fall back to the POSIX engine, which covers things like find with -exec. Most bash, zsh, and POSIX one-liners run unchanged, so both of these work:

ps aux | awk '{if ($3 > 50.0) print $2, $11}'
ps | filter cpu > 50.0 | map pid command | limit 5

Status

fshell is experimental. The README covers installation, examples, and what doesn’t work yet.