A zero-dependency CLI argument parser for let-go, Clojure and Babashka. It is small on purpose: flat commands, Unix-style options, generated help, version output, and simple validation.
- Plain-data spec. Declare the whole CLI as one map.
- Unix-style options. Short (
-v) and long (--verbose) flags,--opt valueor--opt=value, grouped short flags (-vf), global and per-command scopes. - Positional and variadic args. Fixed required positionals, plus an optional variadic that collects the rest.
- Validation without coercion. Per-arg and per-option
:validatepredicates. - Generated help.
--help,-h,help <command>,-v,--version, work with no extra code. Mark a command:hidden?to keep it out of help while it still runs. - Built-in shell completion. A
completioncommand prints a bash, zsh, or fish script; commands, options, andhelpcomplete with zero config. Add:completeto any arg or option to complete dynamic values.
Note
lgx is a dependency and project management tool for let-go.
Add tiny-cli to your dependencies at lgx.edn file:
{:deps {tiny-cli {:git/url "https://github.com/abogoyavlensky/tiny-cli"
:git/tag "<TAG>"}}}Require the core namespace and use run! at the application edge:
(ns my.tool
(:require [tiny-cli.core :as cli]))
(defn do-something!
[{:keys [global args opts]}]
(println "Doing something with" args "and" opts "and global options" global))
(def app
{:name "mycli"
:version "0.1.0"
:doc "My awesome CLI tool."
:commands [{:name "do-something"
:doc "Do something useful."
:run do-something!}]})
(cli/run! app *command-line-args*)app is the CLI spec. Second argument is a vector of command-line tokens without the
executable name or script path.
run! parses args, prints help, version, and parse errors, invokes command
handlers, and exits for built-ins and parse errors. It does not catch handler
exceptions.
This is a small deploy helper as an interpreted let-go script. It accepts one command, one required positional arg, one command option, and one global flag.
main.lg
(ns deploy
(:require [os]
[tiny-cli.core :as cli]))
(defn non-blank?
[s]
(and s (not= "" s)))
(defn deploy-service!
[{:keys [global args opts]}]
(let [service (:service args)
env (:env opts)]
(if (:dry-run? global)
(println "would deploy" service "to" env)
(println "deploying" service "to" env))))
(def app
{:name "deploy"
:version "0.1.0"
:doc "Deploy one service."
:footer "Run 'deploy <command> --help' for more information on a command."
:opts [{:key :dry-run?
:short "n"
:long "dry-run"
:doc "Print the deployment plan."}]
:commands [{:name "service"
:doc "Deploy a service."
:args [{:key :service
:doc "Service name."
:validate {:pred non-blank?
:msg "SERVICE is required."}}]
:opts [{:key :env
:short "e"
:long "env"
:value? true
:default "staging"
:doc "Target environment."}]
:run deploy-service!}]})
(when-not *compiling-aot*
(cli/run! app *command-line-args*))Example:
lgx run -- --dry-run service --env prod apiOutput:
would deploy api to prod
Or build it and run binary:
lgx build
deploy --dry-run service --env prod apiRoot help will look like:
For the deploy example, root help looks like this:
```text
deploy - Deploy one service.
Usage:
deploy [global options] <command> [options] [args]
Commands:
deploy service <SERVICE> Deploy a service.
deploy help [command] Show a command help.
deploy --help Show the tool help.
deploy --version Print version.
Global Options:
-n, --dry-run Print the deployment plan.
Run 'deploy <command> --help' for more information on a command.The first argument to run! is the app spec, plain data. The smallest valid
spec names the tool and one command:
{:name "tool"
:commands [{:name "greet"
:run (fn [_ctx] (println "hi"))}]}:name and :commands are required; every command needs :name and :run.
Everything else is optional. The annotated reference below shows every key with
its possible values; the sections that follow spell out the rules in detail.
{; Executable name, shown in help, version, and usage lines. Required.
:name "tool-name"
; Version string for `--version` and an unclaimed `-v`. When it is absent and
; a version is requested, parsing returns "No version available."
:version "0.1.0"
; Root description shown at the top of root help.
:doc "Short app description."
; Trailing text shown after the command list in root help.
:footer "Run 'tool-name <command> --help' for more information on a command."
; Set false to drop the built-in `completion` command and `__complete`
; endpoint. Defaults to on. See Shell Completions.
:completion? true
; Optional handler run when no command is named (a bare invocation), letting
; the tool do something other than print root help. It receives the same
; context map as a command handler, with :args and :opts empty and :global
; holding any global options that were parsed. `--help`, `-h`, `help`, and
; `--version` still take precedence; when :run is absent, a bare invocation
; prints root help as before.
:run (fn [_ctx] (println "no command given"))
; Global option specs. They may appear before the command, and after it
; unless a command option claims the same spelling. Each value lands in the
; handler's :global map.
:opts
[{; Keyword used in the handler's :global map. Required.
:key :verbose?
; Short spelling without the leading "-". Give :short, :long, or both.
:short "v"
; Long spelling without the leading "--".
:long "verbose"
; true consumes the next token as the option's value; omit it for a flag.
:value? false
; Value placed in :global when the option is absent.
:default nil
; true rejects parsing when the option is missing.
:required? false
; {:pred fn :msg "message"}. The predicate receives the raw string value.
:validate {:pred some? :msg "..."}
; Value-completion candidates: a vector of strings or a (fn [ctx]) returning
; strings. See Shell Completions.
:complete ["a" "b"]
; Description shown in help.
:doc "Print extra output."}]
; Flat list of command specs. Required.
:commands
[{; Command token the user types. Required.
:name "create"
; Command description shown in help.
:doc "Create an item."
; true hides the command from root help; it still runs, and
; `help <command>` still shows its help.
:hidden? false
; Fixed positional args, in order. Every declared arg is required.
:args
[{; Keyword used in the handler's :args map. Required.
:key :name
; Description shown in command help.
:doc "Item name."
; {:pred fn :msg "message"} validation spec.
:validate {:pred non-blank? :msg "NAME is required."}
; Value-completion candidates: a vector of strings or a (fn [ctx]).
:complete (fn [ctx] (candidates ctx))}]
; One arg spec that slurps every token after the fixed :args into a vector.
; At most one per command. See Variadic Trailing Args.
:variadic {:key :cmd
:doc "Command to run; omit for a shell."}
; Command-specific option specs, same shape as global :opts. Each value
; lands in the handler's :opts map.
:opts
[{:key :force?
:short "f"
:long "force"
:doc "Replace an existing item."}]
; Handler called with the parsed context map. Required. See Handler Context.
:run create!}]}Each option needs :short, :long, or both. Duplicate command names, duplicate
arg keys, duplicate option keys, duplicate option spellings, and global/command
option spelling conflicts are spec errors.
Handlers receive one map:
{:global {...}
:args {...}
:opts {...}}For this command:
deploy --dry-run service --env prod apiThe handler receives:
{:global {:dry-run? true}
:args {:service "api"}
:opts {:env "prod"}}CLI values stay as raw strings. tiny-cli applies defaults, checks required
options, and runs validation predicates, but it does not coerce types.
Options come before positional arguments. The first positional token ends option parsing for the command, so every token after it is a positional value:
deploy --dry-run service --env prod api # ok
deploy service api --env prod # error: Options must appear before arguments: --envGlobal options, command options, and the built-ins (--help, -h,
--version, -v) all follow this rule. A global option may also sit before
the command (deploy --dry-run service ...). Use -- to end option parsing
explicitly, which lets a positional value start with a dash:
deploy service -- --weird-nameA command may declare a single :variadic arg to collect everything after its
fixed :args into a vector. This is what run/exec-style commands need.
{:name "run"
:doc "Run a command in a worktree."
:args [{:key :name :doc "Worktree name."}]
:variadic {:key :cmd :doc "Command to run; omit for a shell."}
:run run!}Once the fixed args are filled, parsing switches to rest mode: every remaining
token is appended verbatim — including option-like tokens and a literal -- —
so you don't need a -- separator to pass flags through:
tool run feat-x npm test # {:name "feat-x" :cmd ["npm" "test"]}
tool run feat-x git status -s # :cmd ["git" "status" "-s"]
tool run feat-x git checkout -- f # :cmd ["git" "checkout" "--" "f"]
tool run feat-x # :cmd []The variadic key lands in the handler's :args map alongside the fixed args.
Constraints: the variadic must be the only one per command. A command may
declare its own :opts, but every option — global or command — must come
before the first positional; once the fixed args start, every remaining token
is slurped into the variadic vector. The fixed args remain required; omitting
them is still a Missing argument error.
tiny-cli adds help and version behavior around your app spec.
Root help:
tool help
tool --help
tool -hCommand help:
tool help command
tool command --help
tool command -hVersion:
tool --version
tool -v--version always requests version output. -v requests version output only
when it is not claimed by a global option before the command, or by a global or
command option after the command. If version is requested and :version is
missing, parsing returns No version available.
Command help looks like this:
deploy service <SERVICE> - Deploy a service.
Usage:
deploy [global options] service [options] <SERVICE>
deploy help service
deploy service -h, --help Show help for service.
Args:
SERVICE Service name.
Options:
-e, --env ENV Target environment. Default: staging
Global Options:
-n, --dry-run Print the deployment plan.
Every tiny-cli app gets shell completion built in — no extra code. A hidden
completion command prints a completion script for bash, zsh, or fish,
and a hidden __complete endpoint answers the script's requests on TAB. Out of
the box you get completion for commands, long options, help, and the
completion command's own shell argument.
Install it by sourcing the script for your shell.
Bash — add to ~/.bashrc:
source <(mytool completion bash)Zsh — source it in ~/.zshrc (after compinit), or save it on your fpath:
mkdir -p ~/.zfunc
mytool completion zsh > ~/.zfunc/_mytooland make sure ~/.zshrc has fpath+=~/.zfunc before compinit runs. For an
app name with non-identifier characters, prefer the source form: zsh autoload
needs the file name to match the script's sanitized function id.
Fish:
mytool completion fish > ~/.config/fish/completions/mytool.fishAdd :complete to any arg or option spec to complete dynamic values. It is a
vector of strings, or a one-argument function returning a seq of strings.
tiny-cli prefix-filters the result by the word being typed, so completers
return unfiltered candidates. The function receives a context map with :words
(the words before the cursor), :cur (the word being completed), :command
(the selected command spec), and :positionals (positional values already
given).
{:name "remove"
:doc "Remove a worktree."
:args [{:key :name
:doc "Worktree name."
:complete (fn [_ctx] (existing-worktree-names))}]
:run remove!}A positional whose value is new each time — a name being created — simply omits
:complete, and the shell falls back to its own filename completion. To turn
the whole feature off, set :completion? false on the app.
The main function is:
(cli/run! app argv)Pure and test-friendly helpers are also public:
(cli/parse app argv)
(cli/run-result app argv)
(cli/root-help app)
(cli/command-help app "service")parse returns tagged maps with :status set to :ok, :help, :version,
or :error. run-result calls the selected handler for :ok results without
exiting, which makes command dispatch easy to test.
- wtr - a git worktree CLI built with let-go and lgx, using tiny-cli for argument parsing.
Run the shared .cljc test suite:
lgx test-allMIT License Copyright (c) 2026 Andrey Bogoyavlenskiy