A Unix shell written in Common Lisp, built on SBCL. It leans on SBCL's POSIX bindings and foreign-function interface rather than shelling out, giving it real pipelines, real job control, and a from-scratch line editor with history.
$ make build
$ ./sbsh
example@host:~/Projects/common-lisp/sbsh$ echo hello | tr a-z A-Z
HELLO
- Pipelines —
a | b | c, each stage its own process, wired together withpipe(2)/dup2(2). - Redirections —
<,>,>>,2>,2>>, fd duplication such as2>&1, here-documents (<<EOF,<<-EOFwith tab-stripping,<<'EOF'for a literal body), and here-strings (<<<). - Operators —
&&,||,;, and background&. - Job control — every pipeline runs in its own process group; the shell
hands the terminal to the foreground group with
tcsetpgrp(3)and reclaims it afterwards.Ctrl-Zsuspends;jobs,fg,bg, andkill %nmanage jobs; background completion is reported at the prompt. - History — persisted to
~/.sbsh_history, de-duplicated, navigable with the Up/Down arrows (orC-p/C-n) and searchable withC-r(reverse incremental search). - Arithmetic is Common Lisp:
$((expt 2 10)),$((- $1 1))— prefix syntax, full numeric tower. - Line editor — a raw-mode
readlinewritten from scratch:C-a/C-e(home/end),C-b/C-f(left/right),C-k/C-u(kill),C-w(kill word),C-l(clear), arrow keys, Delete, and Tab filename completion. - Expansion — variables (
$VAR,${VAR},$?,$$,$@,$#, positional$1…); parameter operators${v:-def},${v:=},${v:+},${#v},${v#pat}/${v##pat},${v%pat}/${v%%pat},${v/a/b}/${v//a/b},${v:off:len}; tilde (~, incl. in assignments); command substitution$(…); and globbing (*,?,[abc],[a-z],[!…]). Unquoted expansions are word-split on$IFS;"$@"yields one word per parameter. Expansion happens at execution time, sofalse; echo $?behaves correctly. - Control flow —
if/elif/else/fi,while/until/do/done,for NAME in … / do … done,case … in … esac(with|and glob patterns), andbreak/continue; arbitrarily nested and usable in pipelines. - Shell functions —
name() { … }andfunction name { … }, positional parameters ($1,$@,$#, …), recursion,return,local,{ … }groups. - Multi-line input — input continues across lines while it is incomplete:
an open quote, an unbalanced paren (e.g. a multi-line Lisp form), a trailing
\, or a dangling|/&&/||, with a>continuation prompt. - Shell options —
set -e(errexit),set -u(nounset),set -o pipefail,set -- args;!pipeline negation;$PIPESTATUS(and${PIPESTATUS[n]}). - Builtins —
cd,pwd,exit,echo,export,unset,env,set,read,shift,wait,test/[,return,local,break,continue,history,jobs,fg,bg,kill,type,help,alias,unalias,snapshot,true,false,:.
Because the shell is a live SBCL image, it does things a POSIX shell cannot.
A Lisp escape — any line starting with ( is evaluated as Lisp:
sbsh$ (+ 1 2 3)
6
sbsh$ echo "2^10 = $((expt 2 10))" # arithmetic is just Lisp
2^10 = 1024
Command substitution $( … ) — shell or Lisp:
sbsh$ echo "there are $(ls /usr/bin | wc -l | tr -d ' ') programs"
sbsh$ echo "sum: $((reduce (function +) (list 10 20 30)))"
Lisp functions as pipeline stages — a stage written (...) receives the
prior stage's output as the list lines and emits its result, so processes and
Lisp compose in one pipeline:
sbsh$ ls /etc | (remove-if-not (lambda (s) (search "conf" s)) lines) | sort | head
sbsh$ printf "b\na\nc\n" | (sort lines (function string<))
Interactive error recovery via the condition system — an unknown command signals a correctable condition and offers "did you mean?" restarts:
sbsh$ gti status
sbsh: gti: command not found
Did you mean:
[1] git
Run which? [1-1, Enter to cancel] 1
Hot redefinition — define or redefine a builtin at the prompt and use it immediately; the running image changes underneath you:
sbsh$ (defcommand "hi" (args) (format t "hello ~A~%" (first args)))
sbsh$ hi world
hello world
Homoiconic, queryable history — every line is stored as structured data (text, status, cwd, time, the commands it ran), queryable as Lisp:
sbsh$ (history-where (function failed-p)) ; everything that failed
sbsh$ (history-where (lambda (e) (command-used-p "git" e))) ; every git line
~/.sbshrc is real Common Lisp with a small DSL — see
demo/sample.sbshrc:
(defalias "ll" "ls -laFh")
(defcommand "mkcd" (args) ; a builtin in Lisp
(ensure-directories-exist (concatenate 'string (first args) "/"))
(sh (concatenate 'string "cd " (first args))))
(defcompletion "git" (word) ; context-aware Tab completion
'("status" "commit" "checkout" "branch" "log"))
(defprompt () (format nil "~A sbsh> " (cwd))) ; the prompt is a function
(on-cd (lambda (dir) (declare (ignore dir)))) ; hooks are closuresImage snapshots — snapshot my-shell dumps the live shell (with everything
you have defined this session) to a standalone executable via
save-lisp-and-die.
| Concern | Mechanism |
|---|---|
| Process creation | sb-posix:fork + execvp (via sb-alien) |
| Pipelines | sb-posix:pipe, sb-posix:dup2, sb-posix:close |
| Process groups | sb-posix:setpgid, sb-posix:getpgrp |
| Terminal control | tcsetpgrp/tcgetpgrp/isatty (sb-alien) |
| Raw-mode editing | sb-posix termios (tcgetattr/tcsetattr, ICANON/ECHO) |
| Window size | ioctl(TIOCGWINSZ) via sb-alien |
| Reaping / status | sb-posix:waitpid + WIF*/WEXITSTATUS macros |
| Signals | sb-sys:enable-interrupt (ignore in shell, default in child) |
The shell itself depends on nothing but SBCL — just sb-posix and
sb-alien, which ship with the implementation. The only external dependency
is for the test suite, managed with ocicl:
fiveam— test framework (test system only)
Restore it with:
ocicl install
(Because the core has no external dependencies, make build works from a bare
SBCL checkout — no ocicl required unless you run the tests.)
Prebuilt binaries for Linux (amd64) and macOS (arm64) are attached to each release:
tar xzf sbsh-<version>-<platform>-<arch>.tar.gz
./sbsh-<version>-<platform>-<arch>/sbsh
Or build from source (below).
make build # produce ./sbsh (a standalone, compressed executable)
make test # run the FiveAM suite
make run # load and start the shell without building an image
./sbsh # interactive
./sbsh -c 'ls -l | wc -l' # one-shot
./sbsh script.sh # run a script file
make build
bash demo/demo.sh # non-interactive shell features
bash demo/lisp_demo.sh # Common Lisp integration
python3 demo/interactive_demo.py # line editor + job control, driven over a PTY
See demo/README.md for what each covers.
sbsh.asd system + test-system definitions
src/
package.lisp packages (sbsh + sbsh-user) and global shell state
conditions.lisp condition types + Levenshtein for suggestions
ffi.lisp sb-alien: execvp, tcsetpgrp, tcgetpgrp, isatty
terminal.lisp raw/cooked termios modes, window size
history.lisp history storage/search + structured queryable records
line-editor.lisp raw-mode readline: keys, history, C-r, completion
lexer.lisp tokenizer: quoting, expansion, globbing, $(...) capture
parser.lisp clauses, pipelines (incl. Lisp stages), aliases
jobs.lisp job/process tracking, waitpid reaping
builtins.lisp built-in commands
exec.lisp fork/exec, process groups, fg/bg, Lisp eval, conditions
config.lisp user API + ~/.sbshrc DSL (defalias/defcommand/…)
repl.lisp the interactive loop and prompt
main.lisp entry point (-c / script / interactive)
tests/
suite.lisp unit tests for the pure layers
- Command substitution
$(…)is supported (shell and Lisp), but not backticks. - A here-document whose body lies inside a multi-line compound body
(e.g. a
cat <<EOFinside afor … done) is not collected — heredocs work at the top level and in single-line pipelines. - Aliases are word-level (no embedded pipes/operators).
echois POSIX-style (supports-n, not-e).- Globbing follows the usual dotfile rule (a leading
.must be matched explicitly). - Tested on macOS (arm64) and Linux;
TIOCGWINSZis selected per platform.
