Command-line argument parsing for turning Clojure functions into CLIs
Converts command-line arguments into Clojure data structures for building CLIs. Use when you need to parse `*command-line-args*` into typed options, handle subcommands, or validate CLI input.
/plugin marketplace add hugoduncan/library-skills/plugin install clojure-libraries@library-skillsThis skill inherits all available tools. When active, it can use any tool Claude has access to.
README.mdexamples.cljmetadata.ednCommand-line argument parsing library for transforming Clojure functions into CLIs with minimal effort.
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:
:foo or --foo)Artifact: org.babashka/cli
Latest Version: 0.8.60
License: MIT
Repository: https://github.com/babashka/cli
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.
parse-opts - Returns flat map of parsed optionsparse-args - Separates into :opts, :cmds, :rest-argsExtra arguments don't cause errors by default. Use :restrict for strict validation.
Values are coerced based on specifications, not inferred from values alone. This ensures predictable type handling.
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 --foo style (not :foo):error-fn - Custom error handlerParse 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 --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 []}
:boolean - True/false values:int - Integer:long - Long integer:double - Floating point:symbol - Clojure symbol:keyword - Clojure keywordUse 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]}
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" → booleanedn/read-string: → keywords;; 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}
Map positional arguments to named options:
(cli/parse-opts ["deploy" "production"]
{:args->opts [:action :env]})
;;=> {:action "deploy" :env "production"}
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"]}
(cli/parse-opts ["--verbose" "deploy" "prod" "--force"]
{:coerce {:verbose :boolean :force :boolean}
:args->opts [:action :env]})
;;=> {:verbose true :action "deploy" :env "prod" :force true}
(cli/parse-args ["--name" "app"]
{:require [:name :version]})
;; Throws: Required option: :version
(cli/parse-args ["--verbose" "--debug"]
{:restrict [:verbose]})
;; Throws: Unknown option: :debug
(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
Provide defaults via :exec-args:
(cli/parse-args ["--port" "9000"]
{:coerce {:port :long}
:exec-args {:port 8080 :host "localhost"}})
;;=> {:opts {:port 9000 :host "localhost"}}
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 failedRoute 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
(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"])
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 - SubcommandsGenerate 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 tabular data:
(cli/format-table
{:rows [["Name" "Type" "Default"]
["port" "long" "8080"]
["host" "string" "localhost"]]
:indent 2})
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}}
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}}
#!/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*))
#!/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*))
(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
))
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"))}}}
;; 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}
;; 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}
Arguments after -- are collected as :rest-args:
(cli/parse-args ["--port" "8080" "--" "arg1" "arg2"]
{:coerce {:port :long}})
;;=> {:opts {:port 8080}
;; :rest-args ["arg1" "arg2"]}
(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
(defn safe-parse [args]
(try
(cli/parse-args args {:coerce {:port :long}})
(catch Exception e
{:error (ex-message e)
:opts {}})))
(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))))
(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"}}}])
(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)))
(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*)
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)))
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 %)}}
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))))}}
Since babashka 0.9.160, babashka.cli is built-in. Access via bb -x:
bb -x my-ns/my-fn :port 8080 :verbose true
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)))
babashka.cli works identically on JVM Clojure and native Babashka with minimal performance differences in parsing itself.
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"
Master authentication and authorization patterns including JWT, OAuth2, session management, and RBAC to build secure, scalable access control systems. Use when implementing auth systems, securing APIs, or debugging security issues.