Liking cljdoc? Tell your friends :D

piotr-yuxuan/malli-cli

[]

Command-line arguments with malli.

Clojars badge cljdoc badge GitHub license GitHub issues

What it offers

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:

  • Retrieve value from some configuration management system ;
  • Consider command line arguments as configuration ad-hoc overrides, tokenise them and structure them into a map of command-line options;
  • Merge the two partial values above and fill the blank with default values;
  • If the resulting value conforms to what you expect (schema validation) you may finally use it with confidence.

Simple example

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)))))

Capabilities

See tests for minimal working code for each of these examples.

  • Long option flag and value --long-option VALUE may give
{:long-option "VALUE"}

;; Example schema:
[:map {:decode/cli-args-transformer malli-cli/cli-args-transformer}
  [:long-option string?]]
  • Grouped flag and value with --long-option=VALUE may give
{:long-option "VALUE"}

;; Example schema:
[:map {:decode/cli-args-transformer malli-cli/cli-args-transformer}
  [:long-option string?]]
  • Short option names with -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"}]]]
  • Options that accept a variable number of arguments: -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}]]]
  • Non-option arguments are supported directly amongst options, or after a double-dash so -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?]]
  • Grouped short flags like -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}]]]
  • Non-idempotent options like -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}]]]
  • You may use nested maps in your config schema so that --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?]]]]
  • Namespaced keyword are allowed, albeit the command-line option name stays simple --upload-parallelism 32 may give:
{:upload/parallelism 32}

;; Example schema:
[:map {:decode/cli-args-transformer malli-cli/cli-args-transformer}
 [:upload/parallelism pos-int?]]
  • You can provide your own code to update the result map with some complex behaviour, like for example --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?]]
  • Build a simple summary string (see schema Config above):
  -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
  • Error handling with unknown options:
;; 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"]}

Can you improve this documentation?Edit on GitHub

cljdoc is a website building & hosting documentation for Clojure/Script libraries

× close