| name | babashka.cli |
| description | Command-line argument parsing for turning Clojure functions into CLIs |
babashka.cli
Command-line argument parsing library for transforming Clojure functions into CLIs with minimal effort.
Overview
babashka.cli converts command-line arguments into Clojure data structures, supporting both keyword-style (:opt value) and Unix-style (--opt value) arguments. Designed to minimize friction when creating CLIs from existing Clojure functions.
Key Features:
- Automatic type coercion
- Flexible argument syntax (
:fooor--foo) - Subcommand dispatch
- Validation and error handling
- Boolean flags and negative flags
- Collection handling for repeated options
- Default values
Artifact: org.babashka/cli
Latest Version: 0.8.60
License: MIT
Repository: https://github.com/babashka/cli
Installation
Add to deps.edn:
{:deps {org.babashka/cli {:mvn/version "0.8.60"}}}
Or bb.edn for Babashka:
{:deps {org.babashka/cli {:mvn/version "0.8.60"}}}
Since babashka 0.9.160, babashka.cli is built-in.
Core Concepts
Parsing vs. Args Separation
parse-opts- Returns flat map of parsed optionsparse-args- Separates into:opts,:cmds,:rest-args
Open World Assumption
Extra arguments don't cause errors by default. Use :restrict for strict validation.
Coercion Strategy
Values are coerced based on specifications, not inferred from values alone. This ensures predictable type handling.
API Reference
Parsing Functions
parse-opts
Parse command-line arguments into options map.
(require '[babashka.cli :as cli])
;; Basic parsing
(cli/parse-opts ["--port" "8080"])
;;=> {:port "8080"}
;; With coercion
(cli/parse-opts ["--port" "8080"] {:coerce {:port :long}})
;;=> {:port 8080}
;; With aliases
(cli/parse-opts ["-p" "8080"]
{:alias {:p :port}
:coerce {:port :long}})
;;=> {:port 8080}
Options:
:coerce- Type coercion map (:boolean,:int,:long,:double,:symbol,:keyword):alias- Short name to long name mappings:spec- Structured option specifications:restrict- Restrict to specified options only:require- Required option keys:validate- Validation predicates:exec-args- Default values:args->opts- Map positional args to option keys:no-keyword-opts- Only accept--foostyle (not:foo):error-fn- Custom error handler
parse-args
Parse arguments with separation of options, commands, and rest args.
(cli/parse-args ["--verbose" "deploy" "prod" "--force"]
{:coerce {:verbose :boolean :force :boolean}})
;;=> {:cmds ["deploy" "prod"]
;; :opts {:verbose true :force true}
;; :rest-args []}
Returns map with:
:opts- Parsed options:cmds- Subcommands (non-option arguments):rest-args- Arguments after--
parse-cmds
Extract subcommands from arguments.
(cli/parse-cmds ["deploy" "prod" "--force"])
;;=> {:cmds ["deploy" "prod"]
;; :args ["--force"]}
;; Without keyword opts
(cli/parse-cmds ["deploy" ":env" "prod"]
{:no-keyword-opts true})
;;=> {:cmds ["deploy" ":env" "prod"]
;; :args []}
Coercion
Type Keywords
:boolean- True/false values:int- Integer:long- Long integer:double- Floating point:symbol- Clojure symbol:keyword- Clojure keyword
Collection Coercion
Use empty vector to collect multiple values:
(cli/parse-opts ["--path" "src" "--path" "test"]
{:coerce {:path []}})
;;=> {:path ["src" "test"]}
Typed collections:
(cli/parse-opts ["--port" "8080" "--port" "8081"]
{:coerce {:port [:long]}})
;;=> {:port [8080 8081]}
auto-coerce
Automatic coercion for unspecified options (enabled by default):
(cli/parse-opts ["--enabled" "true" "--count" "42" "--mode" ":prod"])
;;=> {:enabled true :count 42 :mode :prod}
Converts:
"true"/"false"→ boolean- Numeric strings → numbers via
edn/read-string - Strings starting with
:→ keywords
Boolean Flags
;; Flag present = true
(cli/parse-opts ["--verbose"])
;;=> {:verbose true}
;; Combined short flags
(cli/parse-opts ["-vvv"])
;;=> {:v true}
;; Negative flags
(cli/parse-opts ["--no-colors"])
;;=> {:colors false}
;; Explicit values
(cli/parse-opts ["--force" "false"]
{:coerce {:force :boolean}})
;;=> {:force false}
Positional Arguments
Basic args->opts
Map positional arguments to named options:
(cli/parse-opts ["deploy" "production"]
{:args->opts [:action :env]})
;;=> {:action "deploy" :env "production"}
Variable Length Collections
Use repeat for collecting remaining args:
(cli/parse-opts ["build" "foo.clj" "bar.clj" "baz.clj"]
{:args->opts (cons :cmd (repeat :files))
:coerce {:files []}})
;;=> {:cmd "build" :files ["foo.clj" "bar.clj" "baz.clj"]}
Mixed Options and Arguments
(cli/parse-opts ["--verbose" "deploy" "prod" "--force"]
{:coerce {:verbose :boolean :force :boolean}
:args->opts [:action :env]})
;;=> {:verbose true :action "deploy" :env "prod" :force true}
Validation
Required Options
(cli/parse-args ["--name" "app"]
{:require [:name :version]})
;; Throws: Required option: :version
Restricted Options
(cli/parse-args ["--verbose" "--debug"]
{:restrict [:verbose]})
;; Throws: Unknown option: :debug
Custom Validators
(cli/parse-args ["--port" "0"]
{:coerce {:port :long}
:validate {:port pos?}})
;; Throws: Invalid value for option :port: 0
;; With custom message
(cli/parse-args ["--port" "-1"]
{:coerce {:port :long}
:validate {:port {:pred pos?
:ex-msg (fn [{:keys [option value]}]
(str option " must be positive, got: " value))}}})
;; Throws: :port must be positive, got: -1
Default Values
Provide defaults via :exec-args:
(cli/parse-args ["--port" "9000"]
{:coerce {:port :long}
:exec-args {:port 8080 :host "localhost"}})
;;=> {:opts {:port 9000 :host "localhost"}}
Error Handling
Custom error handler:
(defn error-handler [{:keys [type cause msg option]}]
(when (= type :org.babashka/cli)
(println "Error:" msg)
(when option
(println "Option:" option))
(System/exit 1)))
(cli/parse-args ["--invalid"]
{:restrict [:valid]
:error-fn error-handler})
Error causes:
:restrict- Unknown option:require- Missing required option:validate- Validation failed:coerce- Type coercion failed
Subcommand Dispatch
dispatch
Route execution based on subcommands:
(defn deploy [opts]
(println "Deploying to" (:env opts)))
(defn rollback [opts]
(println "Rolling back" (:version opts)))
(def table
[{:cmds ["deploy"] :fn deploy :args->opts [:env]}
{:cmds ["rollback"] :fn rollback :args->opts [:version]}
{:cmds [] :fn (fn [_] (println "No command specified"))}])
(cli/dispatch table ["deploy" "production"])
;; Prints: Deploying to production
(cli/dispatch table ["rollback" "v1.2.3"])
;; Prints: Rolling back v1.2.3
Nested Subcommands
(def table
[{:cmds ["db" "migrate"] :fn db-migrate}
{:cmds ["db" "rollback"] :fn db-rollback}
{:cmds ["db"] :fn (fn [_] (println "db requires subcommand"))}])
(cli/dispatch table ["db" "migrate" "--env" "prod"])
Dispatch Options
Pass options to parse-args:
(cli/dispatch table args
{:coerce {:port :long}
:exec-args {:host "localhost"}})
The :fn receives enhanced parse-args result:
:dispatch- Matched command path:args- Remaining unparsed arguments:opts- Parsed options:cmds- Subcommands
Formatting & Help
format-opts
Generate help text from spec:
(def spec
{:port {:desc "Port to listen on"
:default 8080
:coerce :long}
:host {:desc "Host address"
:default "localhost"
:alias :h}
:verbose {:desc "Enable verbose output"
:alias :v}})
(println (cli/format-opts {:spec spec}))
;; Output:
;; --port Port to listen on (default: 8080)
;; --host, -h Host address (default: localhost)
;; --verbose, -v Enable verbose output
With custom indent:
(cli/format-opts {:spec spec :indent 4})
format-table
Format tabular data:
(cli/format-table
{:rows [["Name" "Type" "Default"]
["port" "long" "8080"]
["host" "string" "localhost"]]
:indent 2})
spec->opts
Convert spec to parse options:
(def spec
{:port {:ref "<port>"
:desc "Server port"
:coerce :long
:default 8080}})
(cli/spec->opts spec)
;;=> {:coerce {:port :long}}
(cli/spec->opts spec {:exec-args true})
;;=> {:coerce {:port :long} :exec-args {:port 8080}}
Option Merging
merge-opts
Combine multiple option specifications:
(def base-opts
{:coerce {:verbose :boolean}})
(def server-opts
{:coerce {:port :long}
:exec-args {:port 8080}})
(cli/merge-opts base-opts server-opts)
;;=> {:coerce {:verbose :boolean :port :long}
;; :exec-args {:port 8080}}
Common Patterns
CLI Application Entry Point
#!/usr/bin/env bb
(ns my-app
(:require [babashka.cli :as cli]))
(defn run [{:keys [port host verbose]}]
(when verbose
(println "Starting server on" host ":" port))
;; ... server logic
)
(def spec
{:port {:desc "Port to listen on"
:coerce :long
:default 8080}
:host {:desc "Host address"
:default "localhost"}
:verbose {:desc "Enable verbose output"
:alias :v
:coerce :boolean}})
(defn -main [& args]
(cli/parse-args args
{:spec spec
:exec-args (:default spec)
:error-fn (fn [{:keys [msg]}]
(println msg)
(println)
(println "Usage: my-app [options]")
(println (cli/format-opts {:spec spec}))
(System/exit 1))}))
(when (= *file* (System/getProperty "babashka.file"))
(apply -main *command-line-args*))
Subcommand CLI
#!/usr/bin/env bb
(ns my-cli
(:require [babashka.cli :as cli]))
(defn build [{:keys [opts]}]
(println "Building with options:" opts))
(defn test [{:keys [opts]}]
(println "Running tests with options:" opts))
(defn help [_]
(println "Commands: build, test"))
(def commands
[{:cmds ["build"]
:fn build
:spec {:target {:coerce :keyword}
:release {:coerce :boolean}}}
{:cmds ["test"]
:fn test
:spec {:watch {:coerce :boolean}}}
{:cmds []
:fn help}])
(defn -main [& args]
(cli/dispatch commands args))
(when (= *file* (System/getProperty "babashka.file"))
(apply -main *command-line-args*))
Configuration File + CLI Override
(require '[clojure.edn :as edn])
(defn load-config [path]
(when (.exists (io/file path))
(edn/read-string (slurp path))))
(defn run [args]
(let [file-config (load-config "config.edn")
cli-opts (cli/parse-args args
{:coerce {:port :long
:workers :long}})
final-config (merge file-config (:opts cli-opts))]
;; Use final-config
))
Babashka Task Integration
In bb.edn:
{:tasks
{:requires ([babashka.cli :as cli])
test {:doc "Run tests"
:task (let [opts (cli/parse-opts *command-line-args*
{:coerce {:watch :boolean}})]
(when (:watch opts)
(println "Running in watch mode"))
(shell "clojure -M:test"))}}}
Long Option Syntax Variations
;; All equivalent
(cli/parse-opts ["--port" "8080"])
(cli/parse-opts ["--port=8080"])
(cli/parse-opts [":port" "8080"])
;; With coercion
(cli/parse-opts ["--port=8080"] {:coerce {:port :long}})
;;=> {:port 8080}
Repeated Options
;; Collect into vector
(cli/parse-opts ["--include" "*.clj" "--include" "*.cljs"]
{:coerce {:include []}})
;;=> {:include ["*.clj" "*.cljs"]}
;; Count occurrences
(defn inc-counter [m k]
(update m k (fnil inc 0)))
(cli/parse-opts ["-v" "-v" "-v"]
{:collect {:v inc-counter}})
;;=> {:v 3}
Rest Arguments
Arguments after -- are collected as :rest-args:
(cli/parse-args ["--port" "8080" "--" "arg1" "arg2"]
{:coerce {:port :long}})
;;=> {:opts {:port 8080}
;; :rest-args ["arg1" "arg2"]}
Error Handling
Validation Failure Context
(defn validate-port [{:keys [value]}]
(and (pos? value) (< value 65536)))
(cli/parse-args ["--port" "99999"]
{:coerce {:port :long}
:validate {:port {:pred validate-port
:ex-msg (fn [{:keys [option value]}]
(format "%s must be 1-65535, got %d"
option value))}}})
;; Throws: :port must be 1-65535, got 99999
Graceful Degradation
(defn safe-parse [args]
(try
(cli/parse-args args {:coerce {:port :long}})
(catch Exception e
{:error (ex-message e)
:opts {}})))
Exit Code Handling
(defn -main [& args]
(let [result (cli/parse-args args
{:spec spec
:error-fn (fn [{:keys [msg]}]
(binding [*out* *err*]
(println "Error:" msg))
1)})]
(if (number? result)
(System/exit result)
(do-work result))))
Use Cases
Build Tool CLI
(def build-commands
[{:cmds ["compile"]
:fn compile-project
:spec {:target {:coerce :keyword
:desc "Compilation target"}
:optimization {:coerce :keyword
:desc "Optimization level"}}}
{:cmds ["package"]
:fn package-project
:spec {:format {:coerce :keyword
:desc "Package format"}}}])
Configuration Management
(defn read-env-config []
(reduce-kv
(fn [m k v]
(if (str/starts-with? k "APP_")
(assoc m (keyword (str/lower-case (subs k 4))) v)
m))
{}
(System/getenv)))
(defn merged-config [args]
(let [env-config (read-env-config)
cli-config (:opts (cli/parse-args args))]
(merge env-config cli-config)))
Testing Wrapper
(defn test-runner [{:keys [opts]}]
(let [{:keys [namespace watch]} opts]
(when watch
(println "Starting test watcher..."))
(apply clojure.test/run-tests
(when namespace [(symbol namespace)]))))
(cli/dispatch
[{:cmds ["test"]
:fn test-runner
:spec {:namespace {:desc "Specific namespace"}
:watch {:coerce :boolean
:desc "Watch mode"}}}]
*command-line-args*)
Performance Considerations
Minimize Parsing Overhead
For frequently called operations, parse once and pass options:
(defn process-files [opts files]
(doseq [f files]
(process-file f opts)))
(let [opts (cli/parse-args args)]
(process-files (:opts opts) (:cmds opts)))
Coercion Functions
Custom coercion functions are called per-value:
;; Efficient: Use keywords for built-in types
{:coerce {:port :long}}
;; Less efficient: Custom function for simple types
{:coerce {:port #(Long/parseLong %)}}
Validation Overhead
Validators run after coercion. Use predicates wisely:
;; Good: Simple predicate
{:validate {:port pos?}}
;; Avoid: Complex validation in predicate
{:validate {:port (fn [p]
(and (pos? p)
(< p 65536)
(not (contains? reserved-ports p))))}}
Platform Notes
Babashka Integration
Since babashka 0.9.160, babashka.cli is built-in. Access via bb -x:
bb -x my-ns/my-fn :port 8080 :verbose true
Clojure CLI Integration
Use with -X flag:
clojure -X:my-alias my-ns/my-fn :port 8080
Add metadata to functions for specs:
(defn ^{:org.babashka/cli {:coerce {:port :long}}}
start-server [opts]
(println "Starting on port" (:port opts)))
JVM vs Native
babashka.cli works identically on JVM Clojure and native Babashka with minimal performance differences in parsing itself.
Cross-Platform Arguments
Quote handling varies by shell:
# Unix shells
script --name "My App"
# Windows cmd.exe
script --name "My App"
# PowerShell
script --name 'My App'
Use positional args to avoid quoting complexity:
script deploy production # Better than: script :env "production"