In software development, engineers require tools that prioritize efficiency and flexibility. When dealing with complex systems, there's a constant need for solutions that should automate tasks, manage configurations, seamlessly integrate with various systems and have full control of the system without depending on GUIs—this is where command-line interfaces (CLIs) come in.
This article will show you how to build a command-line tool using Clingon in Common-lisp. We'll dive to each of every functions and flow on how I built a CLI to manage the Nix flake development environment for running Emacs called 'ems'—a feature mirror for my bash script—Emacs development environment.
To follow along, you'll need your preferred text editor—whether it's any flavors of Emacs or another editor of your choice. I personally use Doom Emacs and SBCL implementation. Clingon is compatible with several Common Lisp implementations, including LispWorks, SBCL, and ECL. If you're using other implementations on macOS or Linux, I recommend checking compatibility with your specific system before starting.
The code we will create in this section will be stored in a file called ems.lisp.
First, get Clingon by cloning its repository into your Quicklisp local-projects directory:
git clone https://github.com/dnaeon/clingon
Next, add Clingon to your local Quicklisp projects by running this command in the REPL:
CL-USER> (ql:register-local-projects)
Finally, load the Clingon library using:
CL-USER> (ql:quickload :clingon)
defpackage :ems
(:use :cl)
(
(:import-from :clingon)
(:export :main))
in-package :ems) (
First I created a new package named ems
. By using (:use :cl)
, we gain access to all standard
Common Lisp functions and macros from the Common Lisp standard package.
Then, (:import-from :clingon)
brings in
the necessary symbols from the Clingon package that we'll use to build a
CLI tool. Finally, (:export :main)
makes
our main function publicly accessible that allows other code to interact
with our CLI tool through this entry point.
defparameter *config*
(list
(:name "ems"
"CLI tool for managing Lisp nix flake"
:description :version "1.0.0"
"[command] [options]"
:usage merge-pathnames #P"myflake/" (user-homedir-pathname))
:dir (4.5)) :time
When I am writing code, I am always concerned with organizing them at
the first place, that's why I'm defining a central configuration using a
property list stored in the special variable named
config. This plist contains key parameters like name,
description, version, usage, directory path, and time value. These
configuration valuese are structured this way to make them easily
accessible when we use them to configure commands with the CLINGON:MAKE-COMMAND
function later in the
top-level program.
Now, let's define some utility functions that will be used to construct for the top-level functions and commands later.
defun get-config (key)
("Get information from the *config*."
getf *config* key)) (
The GET-CONFIG
function is designed to
retrieve values from a property list under config this
will be use later for our top-level command, CLINGON:MAKE-COMMAND
function.
defun log-msg (cmd fmt &rest args)
("Log message if verbose mode is enabled."
when (clingon:getopt cmd :verbose)
(apply #'format t fmt args))) (
The LOG-MSG
function will help us to
log a message when the verbose flag is enabled, and the verbosity check
is performed using (CLINGON:GET-OPT cmd :verbose)
to help us give
information to see what the command is doing.
defun run-cmd (cmd command &rest args)
("Run a command with logging."
"Running command: ~A ~{~A ~}~%" command args)
(log-msg cmd cons command args)
(uiop:run-program (:output :interactive
:error-output :interactive))
In this function, first, we used LOG-MSG
to print the message indicating the
running command and its arguments that will be executed, later. Then,
UIOP:RUN-PROGRAM
is used to actually run
the command. The command and its arguments are passed using cons to
combine the command with the arguments. The :output :interactive
and :error-output :interactive
ensure that the
output and errors from the command are displayed interactively on the
terminal.
defun run! (cmd command &rest args)
("Safely execute commands in myflake directory with logging."
let ((dir (namestring (get-config :dir))))
("Changing to directory: ~A~%" dir)
(log-msg cmd
(uiop:chdir dir)apply #'run-cmd cmd command args))) (
With RUN!
function, It run a command
inside a designated directory—"myflake". First, it retrieves the
directory path from *config*
using (GET-CONFIG :dir)
. Then, it logs a message to
confirm that the directory change is correct while at the same time it
changes the current working directory to the one specified in the
configuration using UIOP:CHDIR
. Finally,
it invokes RUN-CMD
to run the command in
the newly changed directory.
Now we need to create a functions to specify some commands related to the development environment we have in nix flake.
defun run-handler (cmd)
("Run Emacs dev-env."
"nix" "develop" ".#lisp" "-c" "emacs"))
(run! cmd
defun update-handler (cmd)
("Update flake."
"nix" "flake" "update"))
(run! cmd
defun show-handler (cmd)
("Display error in flake."
"nix" "flake" "show"))
(run! cmd
defun version-handler (cmd)
("Check SBCL version."
"nix" "develop" ".#lisp" "-c" "sbcl" "--version"))
(run! cmd
defmacro define-flake-command (name alias description handler)
("Define a flake command with aliases prior to its handler."
let ((maker-name (intern (format nil "MAKE-~A-COMMAND" name))))
(defun ,maker-name ()
`(
(clingon:make-command:name ,name
list ,alias)
:aliases (
:description ,description
:handler ,handler))))
"run" "r" "Run the Emacs shell" #'run-handler)
(define-flake-command "update" "u" "Update the Lisp nix flake" #'update-handler)
(define-flake-command "show" "s" "Show output attribute of the Lisp flake" #'show-handler)
(define-flake-command "sbcl-version" "sv" "Check SBCL's version" #'version-handler) (define-flake-command
As you can see, the RUN-HANDLER
function allows us to launch Emacs within the nix flake, UPDATE-HANDLER
updates the nix flake, SHOW-HANDLER
shows the attributes of the nix
flake, and VERSION-HANDLER
checks the
version of SBCL in the nix environment.The latter, DEFINE-FLAKE-COMMAND
macro helps us define
commands with a name, alias, description, and handler function, which
can later be used as sub-commands under a top-level command later.
defmacro define-option (type short-name long-name description &key key)
("Define a CLI option with standard structure"
`(clingon:make-optiontype
,
:short-name ,short-name
:long-name ,long-name
:description ,description:key ,(or key (intern (string-upcase long-name) "KEYWORD"))))
defun make-cli-options ()
("Create CLI options"
list
(#\v "verbose" "Enable verbose output" :key :verbose)
(define-option :counter #\d "debug" "Enable debug mode" :key :debug))) (define-option :string
For the top-level program, as you can see, we created a macro named
DEFINE-OPTION
that uses the CLINGON:MAKE-OPTION
generic function where it
allows developers to create and add new types of options to ensure that
users can interact with all options through a consistent interface
provided by the CLINGON:MAKE-OPTION
function. Doing it with macro give us liberty and brevity whenever we
add more options rather than doing it in a standard way of creating
MAKE-OPTION
. (See the manual in Clingon
under a quick example).
In make-cli-options
function above, I
defined it by type (e.g., :counter
for
counting occurrences, :string
for a string
argument), short and long names (e.g., -v for verbose=
, -d for debug
), descriptions, and optional
keys.
defun top-level-handler (cmd)
("Checks if there are any extra arguments, if there's any and if it's an unknown command return first condition, Otherwise return the general usage instructions."
let ((args (clingon:command-arguments cmd)))
(cond (args (format t "Unknown command: ~A~%" (first args)))
(t (progn (format t "Usage: ~A~%" (get-config :usage)) (
With the use of CLINGON:COMMAND-ARGUMENTS
, we can have a
top-level handler checks, meaning it checks if there are any extra
arguments provided when we run a command. If there are, it assumes that
the first argument is an unknown command and will alert us. If there are
no arguments, then it shows the general usage instructions for the
command.
defun make-top-level-command ()
("Top-level commands"
(clingon:make-command:name (get-config :name)
:description (get-config :description):version (get-config :version)
:usage (get-config :usage)"Eldriv")
:authors '(
:options (make-cli-options)#'top-level/handler
:handler list
:sub-commands (
(make-run-command)
(make-update-command)
(make-show-command)
(make-sbcl-version-command))))
This function creates the main command structure, a top-level command
for the tool itself, using the CLINGON:MAKE-COMMAND
, where, (:name, :description, :version,:usage and, :authors)
- these are configurations the one that we are going to retrieved from
config using (get-config :key)
function which specify the
basic information about the CLI tool. In :options
, this is where we define command-line
options named MAKE-CLI-OPTIONS
, :handler
responsible for processing top-level
commands and :sub-commands
, it is a list
of sub-commands that we have defined earlier in
define-flake-command macro and it is equivalent to
run
or r
,
update
or u
,
show or s
, and sbcl-version
or sv
.
defun main ()
("Main entry point for the application"
let ((app (make-top-level-command)))
( (clingon:run app)))
This is the main entry point of the application. It creates the top-level command and runs the application using Clingon's run function.
defparameter *app* (make-top-level-command))
(> *app*
EMS 5 sub-commands=4> ;; You can inspect this #<CLINGON.COMMAND:COMMAND name=ems options=
Inspecting the returned instance of make-top-level-command slots would give you something like this:
1004788843}>
#<CLINGON.COMMAND:COMMAND {
--------------------
Class: #<STANDARD-CLASS CLINGON.COMMAND:COMMAND>
--------------------
Group slots by inheritance [ ]
Sort slots alphabetically [X]
All Slots:= NIL
[ ] ALIASES = NIL
[ ] ARGS-TO-PARSE = NIL
[ ] ARGUMENTS = ("Eldriv")
[ ] AUTHORS = #<HASH-TABLE :TEST EQUAL :COUNT 0 {10047DFE93}>
[ ] CONTEXT = "CLI tool for managing Lisp nix flake"
[ ] DESCRIPTION = NIL
[ ] EXAMPLES = #<FUNCTION TOP-LEVEL-HANDLER>
[ ] HANDLER = NIL
[ ] LICENSE = NIL
[ ] LONG-DESCRIPTION = "ems"
[ ] NAME = (#<CLINGON.OPTIONS:OPTION-BOOLEAN-TRUE short=NIL long=bash-completions> #<CLINGON.OPTIONS:OPTION-BOOLEAN-TRUE short=NIL long=version> #<CLINGON.OPTIONS:OPTION-BOOLEAN-TRUE short=NIL long=help> #<CLINGON.OPTIONS:OPTION-COUNTER short=v long=verbose> #<CLINGON.OPTIONS::OPTION-STRING short=d long=debug>)
[ ] OPTIONS = NIL
[ ] PARENT = NIL
[ ] POST-HOOK = NIL
[ ] PRE-HOOK = (#<CLINGON.COMMAND:COMMAND name=run options=3 sub-commands=0> #<CLINGON.COMMAND:COMMAND name=update options=3 sub-commands=0> #<CLINGON.COMMAND:COMMAND name=show options=3 sub-commands=0> #<CLINGON.COMMAND:COMMAND name=sbcl-version options=3 sub-commands=0>)
[ ] SUB-COMMANDS = "[command] [options]"
[ ] USAGE = "1.0.0" [ ] VERSION
The beauty of this is transparency of the inspection system. When you look at the command object, you can see both what you've configured and what's still missing (shown as NIL). You can quickly identify any gaps in your CLI configuration or spot potential issues in your command structure during development.
You can also verify that your command-line help documentation is properly formatted by running this into the REPL:
t) (clingon:print-usage *app*
This displays the full help text exactly as we would see it when
running the command with --help
.
Now that we've completed the core functionality and seen how Clingon
structures our application, we can set up the ASDF system definition. The
application will use MAIN
function as its
entry point, which is standard practice for ASDF systems. Below would be
where we'd write our system definition to tie everything together.
Here’s a system definition for the application we’ve developed so far.
"ems"
(defsystem :name "ems"
:version "1.0.0"
"Eldriv"
:author "CLI tool for managing Lisp nix flake in Emacs"
:description
:depends-on (:clingon :uiop)"intro"
:components ((:module "ems"))))
:components ((:file "program-op"
:build-operation "ems"
:build-pathname "ems:main") :entry-point
To simplify the process of building and cleaning our application, we will use a makefile to automate the steps. This way, we don't have to manually re-enter build commands every time there are changes to the project.
#———————————————————————————————————————————————————————————————————————————————
# HEAD
SHELL := bash
MAKEFLAGS += --warn-undefined-variables
MAKEFLAGS += --no-builtin-rules
.ONESHELL:
.SHELLFLAGS := -eu -o pipefail -c
.DELETE_ON_ERROR:
#———————————————————————————————————————————————————————————————————————————————
# BODY
LISP = sbcl
PROJECT_DIR = $(PWD)
SYSTEM_NAME = ems
BUILD_OUTPUT = ems
.PHONY: all
all: build
.PHONY: build clean
build:
$(LISP) --non-interactive \
--eval '(require :asdf)' \
--eval '(push #p"$(PROJECT_DIR)/" asdf:*central-registry*)' \
--eval '(ql:quickload :$(SYSTEM_NAME))' \
--eval '(asdf:make :$(SYSTEM_NAME))' \
--eval '(quit)'
clean:
rm -f $(BUILD_OUTPUT)
To summarize this build, The makefile
automates the build and cleanup of our project. It uses SBCL
as the Lisp implementation, sets the
project directory with PWD
. and specifies
the system package :ems
. When we run make build
in the command line, it launches SBCL
in non-interactive mode, we will add the project directory
to the central
registry after that we'll load the project using Quicklisp
, and then compiles it using (asdf:make)
for initial build, and lastly, it
exits SBCL once the build is finiished. The clean target removes the
output file, ensuring a fresh start for the next build.
To ensure everything works correctly, it's important to have the right directory structure,
;; ASDF
├── ems.asd ;; Directory
├── intro lisp ;; Lisp file containing the CLI developmenet
│ └── ems.;; makefile ├── makefile
Once the project is set up, we can build it using this command,
$ make build
After building, an executable named ems will be created. You can run it from within the project directory using,
$ ./ems --help
or
$ ./ems
To make it accessible globally, insert this into your shell
configuration files like .bashrc
or .zshenv
,
$ vim .zshenv
Then paste this in the upper level of the config,
export PATH="$HOME<your/project/directory/>:$PATH"
Then to open the Emacs development environment, run,
$ ems r
To check the SBCL version,
$ ems sv
Using CLI tools lets you quickly manage multiple files with one command, saving time compared to clicking and typing commands through them. It boosts efficiency, allows remote access, and helps with troubleshooting. If you're a system administrator, software engineer, data scientist, or anyone in a technical role, the CLI gives you more control and can make your work easier