Skip to content
blairhamPublic

About

A shell parser, interpreter and formatter in Go. One core — the common denominator of real shells — with bash, zsh, ksh, dash and ash as presets over a semantics vector, not forks.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

3,512 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

sh

CI Release CodeQL OpenSSF Scorecard Go version Go Reference License: Apache-2.0

A shell parser and interpreter in Go.

Status: early — these are 0.x tags and they mean it. The core parser and interpreter are in place, and all five dialect binaries grade against a panel of the real shells they model. Measured on macOS against the golden record make oracle writes, 2026-10-04, 4488 cases each:

our binary graded against exact behavioral
bash bash 5.3.20 99.8% 99.8%
zsh zsh 5.9.2 99.8% 99.8%
dash dash 99.9% 99.96%
ksh ksh93 AJM 93u+ 99.1% 99.5%
ash BusyBox 1.37 ash 99.5% 99.6%

ash is graded inside the pinned BusyBox container, with ours cross-compiled and run beside it, so both sides share one platform (#5709).

Every case still short of exact is listed per dialect in internal/oracle/testdata/conformance/, and CI's conformance ratchet fails a change that adds to a list or leaves a fixed case on one. Each listed case has a written reason in docs/spec/by-design.md — the reference shell crashes or hangs, the panel's own builds disagree, a C-library quirk rather than the shell, or a deliberate choice — and a test fails a listed case without one, so the rest of the distance is decided, not unfinished.

Exact is byte-identical stdout, stderr and exit status. Behavioral lets a diagnostic be worded differently so long as the status and the standard output agree; a case that prints something else on standard output is a behavioral difference however it exits. Until 2026-10-03 the column counted the status alone and read 99–100% (#5708).

Two bash builds are in play and they do not always agree. This table is the laptop's bash 5.3.20; the bash suite in CI is graded against debian:sid-slim's 5.3.15, the same release and a different patch level. Where the two disagree, the common denominator of the panel decides and the difference is written down — ~ after a HOME assignment is the worked example, in docs/spec/grammar/expansion.md. Behavior lands spec-first, per CLEANROOM.md.

What makes this different

The core language is the common denominator of real shells — not strict POSIX, and not bash. Dialects (bash, zsh, ksh, dash, ash, plus core and posix) are presets over a semantics vector, not layers, translations or forks.

That distinction is not stylistic. Measuring twenty-eight behavioral axes across dash, bash, ksh93 and zsh produces eight different groupings of those shells — dash sides with zsh on echo, bash sides with zsh on shift, ksh93 sides with zsh on pipelines. No "sh → bash → zsh" ladder explains the data. Dialect is a point in a multi-dimensional space, so the model is a vector of independent switches. See docs/spec/semantics.md.

Execution is gateable and observable from the inside. Sandbox policy, agent permission prompts, AI context and audit trails are interpreter-internal seams rather than wrappers, because eval and source re-parse inside the interpreter and a wrapper never reaches them. See docs/design.md.

Independence

This is an independent implementation. It is not a fork or a port of any existing project, and no file here derives from one. Behavior is learned from POSIX, from vendor manuals, and from running real shell binaries as oracles; it is written down in docs/spec/; code is written from the spec. CLEANROOM.md is the binding rule set.

Installing

From the tap:

brew install blairham/tap/sh

Or from source:

make install                           # /usr/local/libexec/sh — needs sudo to write
make install PREFIX="$HOME/.local"     # no sudo

Six shells: sh, and the dialect binaries bash, zsh, ksh, dash and ash. They land in libexec and not in a bin directory, because they are named after the shells they model — a directory ahead of /bin on PATH would answer for every program on the machine that resolves a shell by name. make install refuses a SHELLDIR that is on PATH unless it is told to go ahead, and the formula keeps them out of Homebrew's bin for the same reason.

So you run one by its full path, and docs/install.md has the whole of it — including what /etc/shells and chsh need to make one of them a login shell, and how to try one first as a terminal profile's command, which is a checkbox to revert rather than a rescue.

The formatter shfmt is the exception. The tap does put it on your PATH, because that rule is about names a machine resolves implicitly — a shebang, a system(), a chsh — and nothing resolves a formatter that way; an editor or a pre-commit hook calls it by name or not at all. Homebrew's own shfmt (mvdan.cc/sh) wants that name too, so the formula declares the conflict and you pick one. make install does not install shfmt at all yet.

A session reads everything a real shell of the same name reads, in the same order: ~/.bashrc, ~/.zshrc, the profile files, and the machine's own file in front of each. docs/install.md has the measured grid.

What is not there yet

The tags are 0.x because this list is real, not because the list is short.

  • Diagnostic wording is a long tail, not the headline gap. This list used to open by saying wording was most of the distance above, and two measurements disagree. One is the ksh re-count beside the table. The other is a bounded sample, first run 2026-09-16: twenty error situations a person actually hits — a missing command, cd onto a file, set -u on an unset name, a redirect that cannot open, division by zero, an unknown option to six different builtins, running a directory, exit foo — each run once through the real shell and through ours under a matching argv[0], compared on stderr and exit status together. Re-measured 2026-09-27: byte-identical 20/20 on every one of the five, 100 of 100, ash included — its reference is a container, and the 16/20 it read on 2026-09-16 is gone along with the ksh and zsh misses of that day. Twenty common situations is a sample and not a census, and the corpus behind the percentages above is two hundred times larger and much stranger. The probe is internal/cmd/diagsample, a few seconds a column, so this bullet can be re-checked rather than inherited — which is how it came to be wrong: its only previous instrument was an hour-long conformance run, so nobody ever ran it to find out.
  • The sandbox contains the shell, not the process tree. A policy decides what the shell opens, runs and signals; a command it was allowed to start makes its own accesses and nothing here sees them. docs/design/sandboxing.md has the scope limit in full.
  • POSIX only. No Windows target — every route into this program is a process group, a controlling terminal, a signal or a syscall.Exec, and a binary that cannot start is worse than no binary.

Using it as a library

The core is a library, and it does not know which shells exist: syntax and interp define the questions, and each shell answers them in its own package under dialect/.

sem, dial, diag := bash.Semantics(), bash.Dialect(), bash.Diagnostics()
r := &interp.Runner{Semantics: &sem, Dialect: &dial, Diagnostics: &diag}
bash.Apply(r)                      // what this shell adds or removes

r.Register("cd", cd)               // only what shell cannot express

prelude, _ := syntax.Parse(`basename() { printf '%s\n' "${1##*/}"; }`, dial)
r.Run(ctx, prelude)                // everything else

Adding a shell adds a directory. Nothing under syntax/ or interp/ names one.

Functions shadow builtins and external commands alike, so most of a dialect needs no Go at all.

Layout

CLEANROOM.md              the rules that keep this independent
docs/install.md           installing, and running it as a login shell
docs/design.md            architecture
docs/lessons-carried-forward.md
                          starting constraints, learned the hard way
docs/spec/                behavioral specs — the wall
  oracle.md               how behavior is learned from real binaries
  shell-matrix.md         measured: which constructs exist where
  semantics.md            measured: where shells conflict
  core.md                 the core language boundary
  style.md                measured: how source is laid back out
  grammar/                per-construct specs
cmd/shfmt                 the formatter — every dialect the parser reads

License

Apache-2.0. See LICENSE and NOTICE. Report vulnerabilities privately — see SECURITY.md. Participation is under the CODE_OF_CONDUCT.md.

Contributions require a signed CLA — see CONTRIBUTING.md and CLA.md. The CLA exists so the project retains the option to offer different terms later; you keep your copyright.

About

A shell parser, interpreter and formatter in Go. One core — the common denominator of real shells — with bash, zsh, ksh, dash and ash as presets over a semantics vector, not forks.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages