fshell

A Unix shell in Rust. Familiar sh syntax, typed pipelines, and a POSIX path for existing scripts.

Why another shell

Standard shells pass text between commands. Information that starts structured in the kernel is formatted to text, then the next command parses it again with awk, sed, grep, and cut. That works but it is easy to break.

Nushell showed that typed pipelines fix much of this, but it changed the syntax and left POSIX behind. I wanted the typed part without relearning the shell. fshell keeps sh syntax and muscle memory, and passes typed records between its native commands.

Structured pipelines

Native commands exchange typed values. You can filter process records and pick fields inside 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 percent CPU. No column counting.

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

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

Interactive use

The prompt includes tab completion grouped by kind, parameter hints, and suggestions drawn from history and the current directory. History lives in SQLite and can be searched.

Aliases expand as you type so you see what will run. Backspace collapses the expansion. The git prompt reads git indexes directly. A transient mode keeps old prompts short. Config edit opens a settings screen for options, themes, and aliases.

Scripts and POSIX

Scripts use .fsh files with let bindings, typed functions, match, try and catch, and heredocs. POSIX shell can sit in the same file when needed. This is a release script in the shape I actually write them:

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 split is deliberate. The sh blocks hold portable checks and archiving idioms that already exist: command presence tests, mkdir -p, tar, shasum, redirections. They behave the same everywhere, so rewriting them in fsh would buy nothing. The fsh parts do orchestration and typed inspection: a small build function plus ls pipelines that read size and name as fields instead of parsing ls output.

Each sh block runs in process against the same environment, so variables and working directory are shared both ways. The same holds for posix and bash blocks, for sourcing bash scripts such as virtualenv activation, and for running whole files with an sh or bash shebang.

Routing also happens per stage inside one pipeline. Each stage resolves in order: aliases, user functions, builtins, then external commands on PATH. A stage name that matches a native keyword still runs the external binary when it carries a flag, so this runs the native sort:

ls | sort size desc

While this runs the system sort in the same pipeline shape:

ls | sort -n -k 2

Lines that fail fsh parsing but look like POSIX fall back to the POSIX engine, which covers cases like find with exec. The result is that bash, zsh, and POSIX lines mostly run as is, with native stages suggested only as a slow migration where they read better. For example, awk column splits can stay until you want the typed form:

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

Status

fshell is experimental and still in development. The README covers installation, examples, and current limitations.