---
title: "Building a REPL OS in Common Lisp, Part 2: The Command System"
date: "2026-06-03"
description: "Part 2 of a series where we build a custom operating system inside a Lisp REPL. We create a proper command system with a registry, dispatcher, and runtime extensibility."
tags: ["lisp"]
series: "Building a REPL OS in Common Lisp"
part: 2
language: "en"
draft: false
---

*Part 2 of a series where we build a custom operating system that runs inside a Lisp REPL.*

---

## Where We Left Off

In Part 1, we built a basic REPL with a prompt, error handling, and a clean exit. But our quit check was hardcoded:

```lisp
(when (and (consp form) (eq (car form) 'quit))
  (format t "~%Goodbye.~%")
  (return))
```

This works, but it doesn't scale. What if we want ten commands? A hundred? We'd have a mess of conditionals. And there's no way for users to discover what commands exist.

Let's build a proper command system. Or sort of.

## What Makes a Command?

A Command will be defined by these three things:

1. **A name**: the symbol users type
2. **A description**: we will create a `help` command that will use it
3. **A function**: the code that actually runs when the command is invoked

In Common Lisp, when you need to bundle data together, you define a struct:

```lisp
(defstruct command
  name
  description
  function)
```

Doing this automatically gives us `make-command` to create commands, and accessors like `command-name`, `command-description`, `command-function` to read their parts. Perks of Lisp.

## The Registry

We need somewhere to store commands, that is our Registry. A hash table works well, and allows us to look up commands by name in constant time:

```lisp
(defvar *commands* (make-hash-table :test 'equal))

(defun register-command (cmd)
  (setf (gethash (symbol-name (command-name cmd)) *commands*) cmd))

(defun find-command (name)
  (gethash (symbol-name name) *commands*))
```

```lisp
`register-command` stores a command under its name. `find-command` retrieves it, returning `nil` if it doesn't exist.

For the attentive reader: yes, `find-command` and `register-command` both wrap the same `gethash` form, and Lisp would let me clean it up. I could define `(setf find-command)` so registration becomes a single assignment, and a small macro can describe the place once so neither function repeats itself. Cool, that version is shorter to look at and longer to understand. Two new concepts, three layers of indirection, in exchange for not writing the same easy expression twice. I left the simple version in because it is more evident.

There's a subtlety here: we use `symbol-name` to convert symbols to strings. Why? In Common Lisp, symbols belong to packages. When we register `'quit` from inside the `REPL` package, we get `REPL::QUIT`. But when a user types `(quit)`, the reader creates `COMMON-LISP-USER::QUIT`, a different symbol. By using the string `"QUIT"` as our key, we sidestep package issues entirely. The `:test 'equal` makes the hash table compare strings properly.


## The Dispatcher

Let's add a small but useful abstraction here, the Dispatcher. Instead of the `REPL` calling `eval` directly, it calls a dispatcher. The dispatcher checks if the input matches a registered command. If so, it runs the command. If not, it falls through to `eval`.

```lisp
(defun dispatch (form)
  (let* ((name (if (consp form) (car form) form))
         (args (if (consp form) (cdr form) nil))
         (cmd (find-command name)))
    (if cmd
        (apply (command-function cmd) args)
        (format t "~%=> ~S" (eval form)))))
```

Let's trace through this:

1. Extract the name: if `form` is a list like `(help)`, the name is `help`. If it's a bare symbol, that's the name.
2. Extract arguments: everything after the first element, or `nil` if there are none.
3. Look up the command: check if this name is registered.
4. If found: call the command's function with the arguments using `apply`.
5. If not found: fall through to `eval` and print the result.

This is what makes our REPL special: commands and Lisp expressions coexist. `(help)` runs our command. `(+ 1 2)` evaluates as normal Lisp.

## Registering Commands

Now we can define commands declaratively. Here's `quit`:

```lisp
(register-command
 (make-command
  :name 'quit
  :description "Exit the REPL"
  :function (lambda ()
              (format t "~%Goodbye.~%")
              (throw 'repl-exit nil))))
```

The function uses `throw` to exit the REPL loop. We'll need a matching `catch` in the main loop (`repl-exit`). More on that shortly.

And here's `help`, which lists all registered commands:

```lisp
(register-command
 (make-command
  :name 'help
  :description "List available commands"
  :function (lambda ()
              (format t "~%Available commands:")
              (maphash (lambda (name cmd)
                         (declare (ignore name))
                         (format t "~%  (~A) - ~A"
                                 (command-name cmd)
                                 (command-description cmd)))
                       *commands*))))
```

The `maphash` function iterates over every entry in the hash table, calling our lambda with two arguments: the key and the value. We don't need the key (we get the name from the command struct itself), but we have to accept it. The `(declare (ignore name))` tells the compiler "I know I'm not using this variable, and that's intentional, not a mistake." It is not very important right now, but could be annoying. Without it, most Common Lisp compilers will warn about an unused variable.

## The New REPL Loop

Our main loop becomes simpler now because `dispatch` handles everything:

```lisp
(defun my-repl ()
  "Start the REPL. Type (help) for commands, (quit) to exit."
  (format t "~%Welcome to REPL OS v2")
  (format t "~%Type (help) for commands, or any Lisp expression.~%")
  (catch 'repl-exit
    (loop
      (format t "~%λ ")
      (force-output)
      (handler-case
          (dispatch (read))
        (error (e)
          (format t "~%ERROR: ~A" e))))))
```

Remember the `quit` command? Notice the `catch 'repl-exit` wrapping the loop. When `quit` calls `(throw 'repl-exit nil)`, control jumps here and the function returns cleanly. This is Common Lisp's non-local exit mechanism. Like exceptions, but more general.

The loop itself just reads, dispatches, and handles errors. All the command logic lives elsewhere. I want to believe that Uncle Bob would be proud.

## Project Structure

Alright, we have advanced quite a bit. We now have enough code to split into multiple files:

```
repl/
  repl.asd       # System definition
  packages.lisp  # Package exports
  core.lisp      # Command struct, registry, dispatch
  repl.lisp      # Main loop and built-in commands
```

### repl.asd

```lisp title="repl.asd"
(asdf:defsystem :repl
  :description "A custom REPL in Common Lisp - Version 2: Commands"
  :version "2.0.0"
  :serial t
  :components
  ((:file "packages")
   (:file "core")
   (:file "repl")))
```

### packages.lisp

We export the command system so users can register their own commands:

```lisp title="packages.lisp"
(defpackage :repl
  (:use :cl)
  (:export
   #:my-repl
   #:command
   #:make-command
   #:command-name
   #:command-description
   #:command-function
   #:register-command
   #:find-command
   #:dispatch
   #:*commands*))
```

### core.lisp

The command system:

```lisp title="core.lisp"
(in-package :repl)

;;; Command structure

(defstruct command
  name
  description
  function)

;;; Command registry

(defvar *commands* (make-hash-table :test 'equal))

(defun register-command (cmd)
  (setf (gethash (symbol-name (command-name cmd)) *commands*) cmd))

(defun find-command (name)
  (gethash (symbol-name name) *commands*))

;;; Dispatcher

(defun dispatch (form)
  (let* ((name (if (consp form) (car form) form))
         (args (if (consp form) (cdr form) nil))
         (cmd (find-command name)))
    (if cmd
        (apply (command-function cmd) args)
        (format t "~%=> ~S" (eval form)))))
```

### repl.lisp

The main loop and built-in commands:

```lisp title="repl.lisp"
(in-package :repl)

;;; Built-in commands

(register-command
 (make-command
  :name 'quit
  :description "Exit the REPL"
  :function (lambda ()
              (format t "~%Goodbye.~%")
              (throw 'repl-exit nil))))

(register-command
 (make-command
  :name 'help
  :description "List available commands"
  :function (lambda ()
              (format t "~%Available commands:")
              (maphash (lambda (name cmd)
                         (declare (ignore name))
                         (format t "~%  (~A) - ~A"
                                 (command-name cmd)
                                 (command-description cmd)))
                       *commands*))))

;;; The REPL

(defun my-repl ()
  "Start the REPL. Type (help) for commands, (quit) to exit."
  (format t "~%Welcome to REPL OS v2")
  (format t "~%Type (help) for commands, or any Lisp expression.~%")
  (catch 'repl-exit
    (loop
      (format t "~%λ ")
      (force-output)
      (handler-case
          (dispatch (read))
        (error (e)
          (format t "~%ERROR: ~A" e))))))
```

## Running It

```bash
cd repl
sbcl
```

```lisp
(load "repl.asd")
(asdf:load-system :repl)
(repl:my-repl)
```

```
Welcome to REPL OS v2
Type (help) for commands, or any Lisp expression.

λ (help)
Available commands:
  (QUIT) - Exit the REPL
  (HELP) - List available commands

λ (+ 1 2 3)
=> 6

λ (quit)
Goodbye.
```

Commands and Lisp work side by side.

## Adding Commands at Runtime

Here's something powerful: because we export the command system, users can add commands while the REPL is running:

```
λ (register-command
    (make-command
     :name 'greet
     :description "Say hello"
     :function (lambda (name)
                 (format t "~%Hello, ~A!" name))))
=> #S(COMMAND :NAME GREET :DESCRIPTION "Say hello" :FUNCTION #<FUNCTION>)

λ (help)
Available commands:
  (GREET) - Say hello
  (QUIT) - Exit the REPL
  (HELP) - List available commands
  
λ (greet "World")
Hello, World!

λ (quit)
Goodbye.
```

The command appears in `help` immediately. This is the Lisp advantage: the system is always live, always extensible. Note that the `REPL` has limitations when using multi-line inputs. This means that  you should input that code as a one-liner, which is tedious. I leave it to the reader to experiment with it :)

## What We Built

Starting from a hardcoded quit check, we built:

1. **Command struct**: bundles name, description, and function
2. **Registry**: hash table for O(1) command lookup
3. **Dispatcher**: routes to commands or falls through to eval
4. **Built-in commands**: `help` and `quit`
5. **Runtime extension**: users can add commands on the fly (and we added the inline command `greet`)

The `REPL` loop itself got simpler. All the complexity moved into a clean, extensible system.

## What's Next

We have commands, but they all run synchronously, which means that the `REPL` blocks until they finish. What if we want a timer that counts down while we keep working? A clock that updates the prompt every second?

In Part 3, we'll add threading. We'll use the library Bordeaux Threads to run background tasks, we will add a `ps` command to list running threads, and `kill` to stop them. Our simple REPL will start to feel like a real operating system.

---

*Next: Part 3: Threading*
