Babashka CLI: turn Clojure functions into CLIs

Babashka CLI is a new library for command line argument parsing. The main ideas:

Given the function:

(defn foo [{:keys [force dir] :as m}]
  (prn m))

and with a little bit of config in your deps.edn, you can call the function from the command line using:

clj -M:foo --force --dir=src

or:

clj -M:foo --force --dir src

which will then print:

{:force true, :dir "src"}

We did not have to teach babashka CLI anything about the expected arguments.

Another accepted syntax is:

clj -M:foo :force false :dir src

and this is parsed as:

{:force false, :dir "src"}

Booleans, numbers and keywords are auto-coerced, but if you want to make things strict, you can use metadata. E.g. if we want to accept a keyword for the option mode:

clj -M:foo :force false :dir src :mode overwrite

and parse it as:

{:force false, :dir "src" :mode :overwrite}

you can teach babashka CLI using metadata:

(defn foo
  {:org.babashka/cli {:coerce {:mode :keyword}}}
  [{:keys [force dir mode] :as m}]
  (prn m))

A leading colon is also accepted (and auto-coerced as keyword):

clj -M:foo :force false :dir src :mode :overwrite

The metadata format is set up in such a way that libraries need not have a dependency on babashka CLI itself.

Did you notice that the -M invocation now becomes almost identical to -X, but without quotes?

clj -M:foo :force true :dir src :mode :overwrite
clj -X:foo :force true :dir '"src"' :mode :overwrite

Let's look at a recent project, http-server, where I used babashka CLI to serve both -X, and -M needs.

The only argument hints defined there right now are:

(def ^:private cli-opts {:coerce {:port :long}})

although that could have been left out since numbers are auto-coerced.

The -main function simply defers to the clojure exec API function (intended for -X usage) with the parsed arguments:

(defn ^:no-doc -main [& args]
  (exec (cli/parse-opts args cli-opts)))

In turn, the exec function adds some light logic making it suitable for command line usage. It prints help when :help is true. Because I'm lazy, I just print the docstring of serve, the function that's going to be called:

(defn exec
  "Exec function, intended for command line usage. Same API as serve but
  blocks until process receives SIGINT."
  {:org.babashka/cli cli-opts}
  [opts]
  (if (:help opts)
    (println (:doc (meta #'serve)))
    (do (serve opts)
        @(promise))))

Also the exec function blocks, preventing the process from immediately exiting.

Now when I add this function to deps.edn using:

:serve {:deps {org.babashka/http-server {:mvn/version "0.1.3"}}
        :main-opts ["-m" "babashka.http-server"]
        :exec-fn babashka.http-server/exec}

it can be called both with -M and -X:

$ clj -M:serve --port 1339

or:

$ clj -M:serve :port 1339

or:

$ clj -X:serve :port 1339

And help printing is supported in both styles:

$ clj -M:serve --help
Serves static assets using web server.
Options:
  * `:dir` - directory from which to serve assets
  * `:port` - port

or:

$ clj -X:serve :help true

The -main function can also be used in babashka scripts:

#!/usr/bin/env bb

(require '[babashka.deps :as deps])
(deps/add-deps
 '{:deps {org.babashka/http-server {:mvn/version "0.1.3"}}})

(require '[babashka.http-server :as http-server])

(apply http-server/-main *command-line-args*)
$ http-server --help
$ http-server --port 1339

I hope you're convinced that with very little code, babashka CLI can let you support both -M, -X style invocations and babashka scripts, while improving command line UX!

Published: 2022-06-24

Tagged: clojure

Archive