l3nz / cli-matic

Compact, hands-free [sub]command line parsing library for Clojure.
Eclipse Public License 2.0
364 stars 29 forks source link
argv-parser clojure command-line command-line-parser graalvm planck subcommands

CLI-matic

Clojars Project ClojarsDownloads

All Contributors

Compact [sub]command line parsing library, for Clojure. Perfect for scripting (who said Clojure is not good for scripting?).

Especially when scripting, you should write interesting code, not boilerplate. Command line apps are usually so tiny that there is absolutely no reason why your code should not be self-documenting. Things like generating help text and parsing command flags/options should not hinder productivity when writing a command line app.

CLI-matic works with GraalVM, giving unbeatable performance for stand-alone command-line apps that do not even need a Java installation - see Command-line apps with Clojure and GraalVM: 300x better start-up times.

CLI-matic also works with Planck REPL for very quick CLJS scripting - see Using with Planck and - last but not least - is compatible with Babashka, that happens to be the gold standard of Clojure scripting.

And if there is no such thing as too much of a good thing, then BabashkaBins lets you write a script using bb and then compile it to a Graal binary automagically.

Using

The library is available on Clojars: https://clojars.org/cli-matic

Or the library can be easily referenced through Github when using deps (make sure you change the commit-id):

{:deps
 {cli-matic
  {:git/url "https://github.com/l3nz/cli-matic.git"
   :sha "374b2ad71843c07b9d2ddfc1d4439bd7f8ebafab"}}}

Features

While targeted at scripting, CLI-matic of course works with any program receiving CLI arguments.

Rationale

Say we want to create a short script, in Clojure, where we want to run a very simple calculator that either sums A to B or subtracts B from A:

$ clj -m calc add -a 40 -b 2
42
$ clj -m calc sub -a 10 -b 2
8
$ clj -m calc --base 16 add -a 30 -b 2
20

We also want it to display its help:

$clj -m calc -?
NAME:
 toycalc - A command-line toy calculator

USAGE:
 toycalc [global-options] command [command options] [arguments...]

VERSION:
 0.0.1

COMMANDS:
   add, a   Adds two numbers together
   sub, s   Subtracts parameter B from A

GLOBAL OPTIONS:
       --base N  10  The number base for output
   -?, --help

And help for sub-commands:

$clj -m calc add -?
NAME:
 toycalc add - Adds two numbers together

USAGE:
 toycalc [add|a] [command options] [arguments...]

OPTIONS:
   -a, --a1 N  0  Addendum 1
   -b, --a2 N  0  Addendum 2
   -?, --help

But while we are coding this, we do not really want to waste time writing any parsing logic. What we care about implementing are the functions add-numbers and sub-numbers where we do actual work; the rest should be declared externally and/or "just happen".

From the point of view of us programmers, we'd like to have a couple of functions like:

(defn add-number
    "Sums A and B together, and prints it in base `base`"
    [{:keys [a b base]}]
    (Integer/toString (+ a b) base))

And nothing more; the fact that both parameters exist, are of the right type, have the right defaults, print the correct help screen, etc., should ideally not be a concern.

So we define a configuration:


(def CONFIGURATION
  {:command     "toycalc"
   :description "A command-line toy calculator"
   :version     "0.0.1"
   :opts        [{:as      "The number base for output"
                  :default 10
                  :option  "base"
                  :type    :int}]
   :subcommands [{:command     "add"
                  :description "Adds two numbers together"
                  :examples    ["First example" "Second example"]
                  :opts        [{:as     "Addendum 1"
                                 :option "a"
                                 :type   :int}
                                {:as      "Addendum 2"
                                 :default 0
                                 :option  "b"
                                 :type    :int}]
                  :runs        add_numbers}
                 {:command     "subc"
                  :description "Subtracts parameter B from A"
                  :opts        [{:as      "Parameter q"
                                 :default 0
                                 :option  "q"
                                 :type    :int}]
                  :subcommands [{:command     "sub"
                                 :description "Subtracts"
                                 :opts        [{:as      "Parameter A"
                                                :default 0
                                                :option  "a"
                                                :type    :int}
                                               {:as      "Parameter B"
                                                :default 0
                                                :option  "b"
                                                :type    :int}]
                                 :runs        subtract_numbers}]}]} ]

It contains:

And...that's it!

Handling multiple layers of sub-commands

As the configuration is recursive (what you have in :subcommands can contain more subcommands) you can have multiple layers of subcommands, each with their own "global" options; or you can have no subbcommands at all by simply defining a :runs function at the main level.

Current pre-sets

The following pre-sets (:type) are available:

You may also specify a set of allowed values in :type, like :type #{:one :two}. It must be a set made of keywords or strings, and the parameter will be matched to allowed values in a case-insensitive way. Keywords do not need (but are allowed) a trailing colon. Sets print their allowed values on help and, on mismatches, suggest possible correct values.

For all options, you can then add:

Return values

The function called can return an integer; if it does, it is used as an exit code for the shell process.

If you return a future, or a promise, or a core.async channel, then CLI-matic will wait until it is fulfilled, or there is a value on the channel, and will use that as a return code (at the moment, only works on the JVM).

Errors and exceptions return an exit code of -1; while normal executions (including invocations of help) return 0.

Positional arguments

If there are values that are not options in your command line, CLI-matic will usually return them in an array of unparsed entries, as strings. But - if you use the positional syntax for short:

{:option "a1" :short 0 :as "First addendum" :type :int :default 23}

You 'bind' the option 'a1' to the first unparsed element; this means that you can apply all presets/defaults/validation rules as if it was a named option.

So you could call your script as:

clj -m calc add --a2 3 5

And CLI-matic would set 'a2' to 3 and have "5" as an unparsed argument; and then bind it to "a1", so it will be cast to an integer. You function will be called with:

{:a1 5, :a2 3}

That is what you wanted from the start.

At the same time, the named option remains, so you can use either version. Bound entries are not removed from the unparsed command line entries.

Validation with Spec (and Expound)

CLI-matic can optionally validate any parameter, and the set of parameters you use to call the subcommand function, with Spec, and uses the excellent Expound https://github.com/bhb/expound to produce sane error messages. An example is under examples/clj as toycalc-spec.clj - see https://github.com/l3nz/cli-matic/blob/master/examples/clj/toycalc-spec.clj

By using and including Expound as a depencency, you can add error descriptions where the raw Spec would be hard to read, and use a nice set of pre-built specs with readable descriptions that come with Expound - see https://github.com/bhb/expound/blob/master/src/expound/specs.cljc

Help text generation

CLI-matic comes with pre-packaged help text generators for global and sub-command help. These generators can be overridden by supplying one or more of your own functions in the :app section of the configuration:

(defn my-command-help [setup subcmd]
  " ... ")

(defn gen-sub-command-help [setup subcmd]
  " ... ")

{:command "toycalc"
 :global-help my-command-help
 :subcmd-help gen-sub-command-help}}

Both functions receive the the configuration and the sub-command it was called with, and return a string (or an array of strings) that CLI-matic prints verbatim to the user as the full help text.

See example in helpgen.clj.

Babashka

This library is compatible with Babashka - a native Clojure interpreter for scripting with fast startup. Its main goal is to leverage Clojure in places where you would be using bash otherwise.

In addition to this library, you need to include babashka's fork of clojure.spec.alpha in your bb.edn. Also see this project's bb.edn for how this project's tests are run with babashka.

See Scripting with Babashka.

Old (non-recursive) configuration

The following configuration, that forced you to use exactly one layer, is still supported and translated automagically.

(def CONFIGURATION
  {:app         {:command     "toycalc"
                 :description "A command-line toy calculator"
                 :version     "0.0.1"}

   :global-opts [{:option  "base"
                  :as      "The number base for output"
                  :type    :int
                  :default 10}]

   :commands    [{:command     "add"
                  :description "Adds two numbers together"
                  :opts        [{:option "a" :as "Addendum 1" :type :int}
                                {:option "b" :as "Addendum 2" :type :int :default 0}]              
                  :runs        add_numbers}

                 {:command     "sub"
                  :description "Subtracts parameter B from A"
                  :opts        [{:option "a" :as "Parameter A" :type :int :default 0}
                                {:option "b" :as "Parameter B" :type :int :default 0}]
                  :runs        subtract_numbers}
                 ]})

Note that custom help-text generators are not translated, as their arity changed in v0.4.0+

Transitive dependencies

CLI-matic currently depends on:

Optional dependencies

To use JSON decoding, you need Cheshire cheshire/cheshire to be on the classpath; otherwise it will break. If you do not need JSON parsing, you can do without.

To use Yaml decoding, you need clj-commons/clj-yaml on your classpath; otherwise it will break. If you do not need YAML parsing, you can do without. Note that up to version 0.4 of cli-matic we used to rely on io.forward/yaml, but it used reflection, and so was incompatible with GraalVM native images.

If Orchestra orchestra is present on the classpath, loading most namespaces triggers an instrumentation. As we already have Expound, we get easy-to-read messages for free.

Tips & tricks

Reducing startup time with skip-macros

If you run your script with the property clojure.spec.skip-macros=true you get significant savings:

    time clj -J-Dclojure.spec.skip-macros=true -m recap sv
    real    0m2.587s - user 0m6.997 - sys   0m0.332s

Versus the default:

    time clj -J-Dclojure.spec.skip-macros=false -m recap sv
    real    0m3.141s - user 0m8.707s - sys  0m0.391s

So that's like half a second for free on my machine.

Capturing current version

If you would like to capture the build environment at compile time (e.g. the exact GIT revision, or when/where the program was built, or the version of your project as defined in project.clj) so you can print meaningful version numbers without manual intervention, you may want to include https://github.com/l3nz/say-cheez and use it to provide everything to you.

Writing a stand-alone script with no external deps.edn

Eric Normand has a nice tip for writing stand-alone scripts that all live in one file:

#!/bin/sh
#_(
   #_DEPS is same format as deps.edn. Multiline is okay.
   DEPS='
   {:deps 
    {cli-matic {:mvn/version "0.3.3"}}}
   '

   #_You can put other options here
   OPTS='
   -J-Xms256m -J-Xmx256m 
   -J-client
   -J-Dclojure.spec.skip-macros=true
   '
exec clojure $OPTS -Sdeps "$DEPS" "$0" "$@"
)

(println "It works!")

And so you have a nice place not to forget to set skip-macros!

BabashkaBins

bbb lets you take a standard Clojure project layout, run it under both JVM Clojure and babashka, and then automates the compilation of your project into a static binary with GraalVM for you when it’s time to distribute it.

Contributing

Before submitting a bug or pull request, make sure you read CONTRIBUTING.md.

Similar projects / inspiration

License

The use and distribution terms for this software are covered by the Eclipse Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php) which can be found in the file epl.html at the root of this distribution. By using this software in any fashion, you are agreeing to be bound by the terms of this license.

You must not remove this notice, or any other, from this software.

Contributors ✨

Thanks goes to these wonderful people (emoji key):


Jason Whitlark

💻

ty-i3

💻

Ivan Kuznetsov

💻

Clemens Damke

💻

Burin Choomnuan

💻

Lee Read

💻

Mike Fikes

💬

This project follows the all-contributors specification. Contributions of any kind welcome!