piotr-yuxuan/malli-cli
Command-line arguments with malli.
This library provides out-of-the-box cli parsing. It exposes a
function which takes a vector of strings args
as input and return a
map that can later be merged with the config. As such it intends to
play nicely with configuration tools, so you may have the following
workflow:
TL;DR: account for some changes ahead, but it is usable as is and should probably match your use case.
The API can be expected to change as we're still in version
0.0.x
. However, given the examples below, one would say that it
covers all of the use cases one could think about regarding
command-line arguments.
However, a command-line interface is not restricted to arguments but should also gracefully handle environement variables. It is currently possible (see example below) but it could probably be made much simpler and straightforward. Please your input on this if you have any 🙂!
Let's consider this config schema:
(require '[piotr-yuxuan.malli-cli :as malli-cli])
(require '[malli.core :as m])
(def Config
(m/schema
[:map {:closed true}
[:help [boolean? {:short-option "-h"
:optional true
:arg-number 0}]]
[:upload-api [string?
{:short-option "-a"
:default "http://localhost:8080"
:description "Address of target upload-api instance."}]]
[:log-level [:enum {:decode/string keyword
:short-option "-v"
:short-option/arg-number 0
:short-option/update-fn (fn [options {:keys [path schema]} _cli-args]
(update-in options path (malli-cli/children-successor schema)))
:default :error}
:off :fatal :error :warn :info :debug :trace :all]]
[:proxy [:map
[:host string?]
[:port pos-int?]]]]))
Also, here is what your configuration system provides:
{:upload-api "https://example.com/upload"
:proxy {:host "https://proxy.example.com"
:port 3128}}
You may invoke your Clojure main function with:
lein run \
--help -vvv \
-a "https://localhost:3004"
and the resulting configuration passed to your app will be:
{:help true
:upload-api "https://localhost:3004"
:log-level :debug
:proxy {;; Nested config maps are supported
:host "http://proxy.example.com"
;; malli transform strings into appropriate types
:port 3128}
From a technical point of view, it leverages malli coercion and decoding capabilities so that you may define the shape of your configuration and default value in one place, then derive a command-line interface from it.
(require '[piotr-yuxuan.malli-cli :as malli-cli])
(require '[malli.core :as m])
(defn load-config
[args]
(deep-merge
;; Default value
(m/decode Config {} mt/default-value-transformer)
;; Value retrieved from configuration system
(:value (configure {:key service-name
:env (env)
:version (version)}))
;; Command-line overrides
(m/decode Config args malli-cli/simple-cli-options-transformer)))
(defn -main
[& args]
(let [config (load-config args)]
(if (m/validate Config config)
(app/start config)
(do (log/error "Invalid configuration value"
(m/explain Config config))
(Thread/sleep 60000) ; Leave some time to retrieve the logs.
(System/exit 1)))))
See tests for minimal working code for each of these examples.
--long-option VALUE
may give{:long-option "VALUE"}
;; Example schema:
[:map {:decode/cli-args-transformer malli-cli/cli-args-transformer}
[:long-option string?]]
--long-option=VALUE
may give{:long-option "VALUE"}
;; Example schema:
[:map {:decode/cli-args-transformer malli-cli/cli-args-transformer}
[:long-option string?]]
-s VALUE
may give{:some-option "VALUE"}
;; Example schema:
[:map {:decode/cli-args-transformer malli-cli/cli-args-transformer}
[:some-option [string? {:short-option "-s"}]]]
-a -b val0 --c val1 val2
{:a true
:b "val0"
:c ["val1" "val2"]}
;; Example schema:
[:map {:decode/cli-args-transformer malli-cli/cli-args-transformer}
[:a [boolean? {:arg-number 0}]]
[:b string?]
[:c [string? {:arg-number 2}]]]
-a 1 ARG0 -b 2 -- ARG1 ARG2
may be
equivalent to:{:a 1
:b 2
:piotr-yuxuan.malli-cli/arguments ["ARG0" "ARG1" "ARG2"]}
;; Example schema:
[:map {:decode/cli-args-transformer malli-cli/cli-args-transformer}
[:a [boolean? {:arg-number 0}]]
[:b string?]]
-hal
are expanded like, for example:{:help true
:all true
:list true}
;; Example schema:
[:map {:decode/cli-args-transformer malli-cli/cli-args-transformer}
[:help [boolean? {:short-option "-h" :arg-number 0}]]
[:all [boolean? {:short-option "-a" :arg-number 0}]]
[:list [boolean? {:short-option "-l" :arg-number 0}]]]
-vvv
are supported and may be rendered as:{:verbosity 3}
;; or, depending on what you want:
{:log-level :debug}
;; Example schemas:
[:map {:decode/cli-args-transformer malli-cli/cli-args-transformer}
[:log-level [:and
keyword?
[:enum {:short-option "-v"
:short-option/arg-number 0
:short-option/update-fn (fn [options {:keys [in schema]} _cli-args]
(update-in options in (malli-cli/children-successor schema)))
:default :error}
:off :fatal :error :warn :info :debug :trace :all]]]]
[:map {:decode/cli-args-transformer malli-cli/cli-args-transformer}
[:verbosity [int? {:short-option "-v"
:short-option/arg-number 0
:short-option/update-fn (fn [options {:keys [in]} _cli-args]
(update-in options in (fnil inc 0)))
:default 0}]]]
--proxy-host https://example.org/upload --proxy-port 3447
is expanded as:{:proxy {:host "https://example.org/upload"
:port 3447}}
;; Example schema:
[:map {:decode/cli-args-transformer malli-cli/cli-args-transformer}
[:proxy [:map
[:host string?]
[:port pos-int?]]]]
--upload-parallelism 32
may give:{:upload/parallelism 32}
;; Example schema:
[:map {:decode/cli-args-transformer malli-cli/cli-args-transformer}
[:upload/parallelism pos-int?]]
--name Piotr
:{:vanity-name ">> Piotr <<"
:original-name "Piotr"
:first-letter \P}
;; Example schema:
[:map {:decode/cli-args-transformer malli-cli/cli-args-transformer}
[:vanity-name [string? {:long-option "--name"
:update-fn (fn [options {:keys [in]} [username]]
(-> options
(assoc :vanity-name (format ">> %s <<" username))
(assoc :original-name username)
(assoc :first-letter (first username))))}]]
[:original-name string?]
[:first-letter char?]]
-h --help nil
-a --upload-api "http://localhost:8080" Address of target upload-api instance.
-v --log-level :error
--proxy-host nil
--proxy-port nil
;; Example schema:
[:map {:decode/cli-args-transformer malli-cli/cli-args-transformer}
[:my-option string?]]
;; Example input:
["--unknown-long-option" "--other-option" "VALUE" "-s"}
;; Exemple output:
#:piotr-yuxuan.malli-cli{:unknown-option-errors ({:arg "-s"} {:arg "--other-option"} {:arg "--unknown-long-option"}),
:known-options ("--my-option"),
:arguments ["VALUE"],
:cli-args ["--unknown-long-option" "--other-option" "VALUE" "-s"]}
;; Example schema.
(def Config
[:map
[:options
[:map {:decode/cli-args-transformer malli-cli/cli-args-transformer}
[:commands [:vector {:long-option "--command"
:update-fn (fn [options {:keys [in]} [command]]
(update-in options in (fnil conj []) command))}
keyword?]]]]
[:env
[:map
[:user string?]
[:pwd string?]]]])
;; Example code. To keep it straightforward, config here only comes
;; from malli-cli. The `:env` map will be stripped of extra keys by the
;; transformer.
(require '[camel-snake-kebab.core :as csk])
(defn load-config
[env args]
(m/decode Config
{:options args
:env (into {} env)}
(mt/transformer
(mt/key-transformer {:decode csk/->kebab-case-keyword})
malli-cli/cli-args-transformer
mt/strip-extra-keys-transformer
mt/default-value-transformer
mt/string-transformer)))
;; Example usage:
(load-config
(System/getenv)
["--command" "init-db" "--command" "conform-repo"])
;; => {:options {:commands [:init-db :conform-repo]},
:env {:pwd "~",
:user "piotr-yuxuan"}}
;; Another example usage, showing config validation:
(let [config (load-config
(System/getenv)
args)]
(when-not (m/validate Config config)
(pp/pprint (m/explain m/validate Config config))
(System/exit 1)))
Can you improve this documentation?Edit on GitHub
cljdoc is a website building & hosting documentation for Clojure/Script libraries
× close