OSS updates May and June 2026

In this post I'll give updates about open source I worked on during May and June 2026.

To see previous OSS updates, go here.

Sponsors

I'd like to thank all the sponsors and contributors that make this work possible. Without you, the below projects would not be as mature or wouldn't exist or be maintained at all! So a sincere thank you to everyone who contributes to the sustainability of these projects.

gratitude

Current top tier sponsors:

Open the details section for more info about sponsoring.

Sponsor info

If you want to ensure that the projects I work on are sustainably maintained, you can sponsor this work in the following ways. If you work for a company that uses my OSS, please ask your employer, that would be even better. Thank you!

Updates

A lot happened in the past two months! Not just coding but also...

Babashka Conf 2026 and Dutch Clojure Days

Three years after the initial installment, Babashka Conf 2026 happened on May 8th at the OBA Oosterdok library in Amsterdam, with David Nolen, primary maintainer of ClojureScript, as our keynote speaker. Thanks to our sponsors Nubank, Exoscale, Bob, Flexiana and Itonomi, to Wendy Randolph for hosting, and to all the speakers, volunteers and attendees who made it such an inspiring day. You can watch all the videos here. Thanks to Ray for recording! The day after, Dutch Clojure Days 2026 rounded out a full weekend of Clojure in Amsterdam, where I did a presentation about ClojureScript and async/await. The video of that is hopefully coming soon.

Babashka Conf 2026 speakers and organizers

Babashka Conf 2026. From left to right: David Nolen, Jen Myers, Adrian Smith, Josh Glover, Rahul Dé, Arne Brasseur, Christoph Neumann, Timo Kramer, Jynn Nelson, Wendy Randolph.

Upcoming: babashka workshop at the Clojure/conj

I'm pleased to announce that Rahul Dé and I will be hosting a babashka workshop at the Clojure Conj 2026. The workshop will showcase various use cases of babashka. This hands-on workshop covers the whole lifecycle of a babashka tool, from a quick script to a published, installable CLI app. We assume you know the basics of Clojure and won't explain the language itself. Topics include:

  • Setting up your dev environment
  • Managing projects with babashka tasks (bb.edn)
  • A tour of built-in libraries (fs, process, http-client, and more)
  • Writing and running tests
  • Building a CLI with subcommands and automatic help
  • Programming a terminal UI (TUI)
  • Producing a small web app
  • Publishing via GitHub or as an installable tool with bbin

Every concept comes with an exercise, building toward one culminating CLI app. There will be lots of interaction and fun!

Blog posts

Besides this update I published two blog posts in the past two months:

and a ClojureScript reference on async functions:

Projects

Babashka CLI got the most attention this cycle. I added automatic --help generation for dispatch-based CLIs and shell tab completion for bash, zsh, fish, PowerShell and Nushell. There's a dedicated post with a "build your own git" walkthrough linked above. I also made Babashka CLI Squint compatible, so CLIs built with it run on Node.js and in the browser, published as the @babashka/cli npm package. Also ClojureDart support for Babashka CLI got added.

Squint saw a large amount of work that kept going right into early July: a browser nREPL, dynamic vars and binding that survive across separately-compiled ESM modules, an EDN reader, cached lazy seqs, defrecord and a wide set of core protocols, and a big compatibility push to make it pass jank's clojure-test-suite. Replicant now runs on Squint too. I added key diffing to Reagami and did some benchmarks, showing that Reagami on squint performs in the ballpark of React. The benchmark also shows that Replicant on Squint performs even a tad better than on ClojureScript. Not that this makes a huge difference in practice, but it's nice to validate the idea that Squint, for typical apps, can be a valid CLJS replacement while not giving up that much in terms of Clojure features.

A security issue in SCI deserves a callout. A string type-hint could bypass the :classes allowlist and statically initialize any class on the classpath at analysis time. If you sandbox untrusted code with SCI, upgrade to 0.13.53. ClojureDart support and fine-grained interop control (which was needed for cljd support since it has no reflection) also got added. You can now make REPLs for your mobile apps!

Since porting was a theme these past months, I'll mention another one: babashka.fs now runs on Node.js via ClojureScript and squint, published as the @babashka/fs npm package.

Here are some highlights per project. See each project's CHANGELOG.md for the full list.

  • babashka CLI: Turn Clojure functions into CLIs!

    • Automatic --help generation for dispatch CLIs, plus shell completions for bash, zsh, fish, PowerShell and Nushell (#112, #24, #95). I wrote a full post on it with a "write your own git" walkthrough: babashka CLI: automatic --help and shell completions
    • Exposed the underlying building blocks so you can roll your own custom CLI parsing: parse-opts*, coerce-opts, validate-opts, apply-defaults, table->tree
    • dispatch now accepts a tree directly (as returned by table->tree), and subcommand order is preserved in printed help and completions
    • Squint support and a new @babashka/cli npm package
    • ClojureDart support (#182)
    • opts->table accepts :columns to override the auto-detected columns (#148, thanks Jan Seeger)
    • Better error messages: negation errors now name the base option, --no-foo on a non-boolean option errors instead of silently coercing, and :edn :coerce now requires an explicit value (#166, #174)
    • Thanks to @lread for a lot of documentation review and general maintenance during this cycle
    • Full changelog
  • Squint: CLJS syntax to JS compiler

    • Browser nREPL support landed, followed by a number of REPL/nREPL fixes: #815 (str wrapping tripping esbuild), #819 (macro changes not picked up in watch mode), #820 (:macros option ignored from JS callers) and #832 (nREPL server hanging on advertised-but-unimplemented ops)
    • The CLI now gets its --help, usage and error handling from babashka.cli's dispatch, plus shell tab completion
    • Dynamic vars and binding now work via a mutable box, safe across separately-compiled ESM modules; syntax-quote resolves symbols through the current namespace and aliases like Clojure. defprotocol got :extend-via-metadata support.
    • reify added
    • clojure.walk added
    • Added squint.edn/clojure.edn with a ~300-line EDN reader
    • Printing is now done through *print-fn*, print, pr and with-out-str, like CLJS
    • Lazy seqs are now cached instead of recomputed on every consumption, matching CLJS's chunked-seq behavior
    • A big push for compatibility with jank's clojure-test-suite: dozens of core functions (sorted-map, hash-map, subvec, pop, merge, keys/vals, peek, transducers, = on dates/regexes/lazy seqs, and more) now throw or behave exactly like CLJS instead of the old loose JS semantics, alongside full built-in cljs.test support
    • #771: dead-code elimination for varargs/multi-arity functions, now emitted via ... spread
    • Replicant support landed, with an example
    • Added defrecord, record? and the IRecord marker protocol. Records store their fields as own string-keyed properties and implement the map-facing protocols, so keyword lookup, keys, seq, assoc, conj and = all work through the regular core functions; the generated implementations are shared runtime functions imported only by files that use defrecord
    • Added a large set of core protocols so custom types participate in the standard functions: ILookup, IAssociative, IMap, ICounted, ICollection, IEquiv, ISet, the transient protocols, and IAtom/IDeref/IReset/ISwap/IWatchable (so a reagent-style reactive atom can be a plain deftype)
    • Compile-time namespace resolution: cljs.analyzer.api/resolve now sees vars of built-in library namespaces like clojure.string, plus :squint/compile-time forms and fixes for macro self-use
    • Full changelog
  • clj-kondo: static analyzer and linter for Clojure code that sparks joy.

    • NEW: macros from source. A defmacro (plus any supporting defn/defn-/def) tagged with {:clj-kondo/macroexpand-hook true} is automatically extracted into .clj-kondo/ and registered as a :macroexpand hook on the next run. See doc/hooks.md
    • Support for async/await in ClojureScript: bumped built-in CLJS analysis to 1.12.145 and added the :await-without-async-fn and :misplaced-async-metadata linters
    • #2822: NEW linter :alias-same-as-ns, warns when an alias equals the namespace it aliases (default :off) (@tomdl89)
    • #2807: NEW linter :conditional-build-up, warns on successive (if pred (assoc m ...) m) rebinding and suggests cond-> (default :off) (@walber-araujo)
    • #2062: NEW linter :if-x-x-y, suggests (or x y) instead of (if x x y) (default :off) (@jramosg)
    • #2818: fix :redefined-var false positive across files declaring the same namespace
    • #2814: fix :protocol-method-arity-mismatch false positive for definterface declaring the same method with multiple arities (@jramosg)
    • #2817: warn on recur inside a vector, map or set literal, since recur is never in tail position there
    • #2854: fix :invalid-arity false positive when an inner binding or fn param shadows a local function name (@yuhan0)
    • Performance work on the rewrite-clj parser and analysis internals: efficient get-in/select-keys, faster sexpr, leaner node allocation (@alexander-yakushev)
    • Deprecation notice: 2026.05.25 is the last release to include the clj-kondo LSP server and VS Code extension; use clojure-lsp instead, which embeds clj-kondo
    • Queued for the next release: early support for the Clojure 1.13 map destructuring keys (:keys!/:syms!/:strs!), including inferring required keys and reporting them at call sites (#2870)
    • Full changelog
  • SCI: Configurable Clojure/Script interpreter suitable for scripting

    • ClojureDart support, with a Flutter REPL example
    • Instance/static method and field overrides plus a :closed allowlist for :classes, giving fine-grained control over host interop; see the interop control docs. Also 1.6x faster instance-method interop on babashka
    • Security fix (sandbox escape): a string type-hint (e.g. ^"some.Class" x) bypassed the :classes allowlist, loading and static-initializing any class on the classpath at analysis time. Only affects sandboxing of untrusted code via :classes; upgrade to 0.13.53
    • Add an :interrupt-fn option: a zero-arg function called on every interpreted fn entry, so host code can interrupt or cancel a running SCI eval (thanks @whilo)
    • Add sci.interrupt/interrupt! to throw an interrupt that sandboxed try/catch cannot catch, and gate finally and the regex functions (re-matches/re-find/re-seq, JVM) through :interrupt-fn too, closing off ways to mask an interrupt and escape the sandbox #1044
    • Fix copy-var incorrectly marking a function as inlined when its unqualified name collided with a clojure.core/cljs.core inlined var (e.g. a custom get), silently breaking with-redefs (@verberktstan)
    • Fix cross-namespace defrecord/deftype type symbol resolution via alias (e.g. (instance? r/Foo x)), fixing nbb#410
    • Fix a self-require (a namespace requiring itself) being reported as a cyclic load dependency
    • Full changelog
  • fs: file system utility library for Clojure

    • Released 0.5.34 with Node.js support (#265): fs now runs on Node.js via ClojureScript and Squint / JavaScript, published as the @babashka/fs npm package. Most functions are supported. The JVM behavior is the reference implementation so all operations are synchronous, and the glob syntax is reimplemented from scratch to match the JVM. File times are BigInt nanoseconds to preserve sub-millisecond precision. zip is left out since Node.js has no native support for it
    • Added spit and slurp on both the JVM and Node.js
    • exec-paths returns [] when PATH is unset or blank instead of throwing
    • @lread did a thorough review pass making the return values of copy, copy-tree, delete-tree, zip/unzip, gunzip and the setters explicit and documented/tested (#197)
  • Babashka: native, fast starting Clojure interpreter for scripting.

    • Working towards a new release integrating all the newest updates in Babashka CLI and babashka.fs. Most importantly I'm working on autocompletions added for tasks defined in bb.edn.
    • #1979: fix with-redefs on copied vars (e.g. org.httpkit.client/get) incorrectly treated as inlined
    • Add org.jline.keymap.BindingReader for reading key bindings in terminal applications, completing the input side of the bundled JLine API
    • #1982: add clojure.lang.ChunkedCons, clojure.lang.APersistentVector$SubVector, clojure.lang.ArraySeq, clojure.lang.PersistentVector$ChunkedSeq, java.util.AbstractCollection and java.util.Queue to :instance-checks (@paintparty)
    • Added a terminal tetris example (examples/tetris.clj) built on JLine's Display and AttributedString, showing off the new terminal APIs
    • Full changelog
  • Reagami: A minimal zero-deps Reagent-like for Squint and CLJS

    • Added keyed reconciliation (#40): support :key on children for stable node identity, so diffing reuses nodes instead of recreating them
    • Fixed CLJS :lite-mode compatibility and added it to CI (#41)
    • Added a benchmarks page comparing reagami against CLJS React wrappers and React-free solutions, with mermaid charts to visualize the results (#42, #43)
    • Expanded the README with an ADR on the unkeyed reconciliation algorithm
  • Cream: Clojure + GraalVM Crema native binary

    • I was finally able to reproduce an issue with core.async and filed this upstream
    • Once this is fixed I'm going to consider crema more seriously and play with the thought that this could be a substrate for "Babashka next".
  • html: Html generation library inspired by squint's html tag

    • Fixed inline style maps emitting a literal \n between declarations via pr-str, which produced invalid CSS and dropped every declaration after the first (@cycl1st)
    • Only render a map attribute value as CSS when the key is style; other map-like values (e.g. records) now render via str (@telekid)
    • Fixed a symbol-valued attribute resolving to its runtime value instead of its literal name
  • Edamame: configurable EDN and Clojure parser with location metadata and more

    • Added ClojureDart support (non-indexing plain readers matching tools.reader, zero-literal parsing fix, and more)
    • With :auto-resolve-ns, bare syntax-quoted symbols now resolve to the current namespace, matching Clojure's behavior
  • Neil: A CLI to add common aliases and features to deps.edn-based projects

    • #261: neil dep upgrade now upgrades unstable deps (e.g. release candidates) to a newer unstable version when no newer stable version exists
    • Added a README note on brew trust for users who installed neil before Homebrew introduced tap trust
    • The next neil version will make use of the new Babashka CLI features which is already prepared in a PR
  • Nbb: Scripting in Clojure on Node.js using SCI

    • #410: fixed a regression, introduced by the async/await work in #408, where a defrecord/deftype type symbol referenced through a namespace alias (e.g. (instance? r/Foo x)) failed to resolve
  • deps.clj: a faithful port of the clojure CLI bash script to Clojure

    • As always, catching up with the most recent Clojure CLI versions
  • Pod-babashka-gozxing: a babashka pod for QR code and barcode decoding/encoding, backed by gozxing

    • Initial release 0.0.1, installable via the pod registry
  • Graal-build-time: initialize Clojure classes at build time for GraalVM native-image

    • #55: munge package names for namespaces with special characters

Contributions to third party projects:

  • ClojureScript: documented the async/await support from last cycle on the ClojureScript site, including an enhanced reference (#423, #424)
  • Nexus: a data-driven state management library by Christian Johansen. I ported the core engine and test suite to run under squint and added a cljs test runner alongside the existing kaocha setup, so both babashka and squint stay covered in CI (#15, #16, merged)
  • Replicant: a data-driven DOM rendering library by Christian Johansen. I made Replicant itself run under Squint (converting dom.cljs to .cljc, adjusting core.cljc for portability), added babashka/squint test runners and wired them into CI, and fixed a multi-root render bug under squint by switching DOM state tracking to a node-map (#71, #72, merged)

Other projects

These are (some of the) other projects I'm involved with but little to no activity happened in the past two months.

Click for more details

Published: 2026-07-06

Tagged: clojure oss updates

Automatic help and completions in Babashka CLI

Babashka CLI is a library to write command line tools. It is available in babashka by default. This library was born out of some frustration with clojure -X's functionality where people have to write raw EDN on the command line, which to me isn't a good user experience (especially not for people using Powershell where quoting rules are different than in bash and zsh). Babashka wants to give Clojure users a good scripting experience, no matter what OS or shell you are using.

While Babashka CLI had all the ingredients for parsing and formatting options (for help) and for multi-command (or subcommand) style (e.g. git remote show origin) invocations, you still had to write your own --help functionality. Also Babashka CLI didn't offer anything for getting shell completions. These two gaps existed as open Github issues for about three years now. What held me back in implementing these features was: A) I found help output for multi-command CLIs always a bit too opinionated. Every CLI I knew was doing it differently. Which one should I pick for bb.cli? and B) to implement shell completions I actually had to know something about shells I did not use personally. After looking at a couple of other libs like Howard M. Lewis Ship's cli-tools, and Lambdaisland CLI and a couple more non-Clojure libraries, I decided I should just pick a help output that looks reasonable and offer an API to do your own thing if you want to do that. For implementing the completion support I re-used the branch that Sohalt and I worked on in 2024. Additionally I used Claude Code to get this work over the hump. Studying how Powershell or nushell completions work in detail just isn't that interesting to me and I was happy to defer most of the shell-specific nitty-gritty. One extra bonus feature is the nested command notation instead of the "table". This already existed in Babashka CLI for a while, but it's now exposed for users.

The features described in this post are available as of Babashka CLI v0.11.73:

org.babashka/cli {:mvn/version "0.11.73"}

Let's dig into an example to learn more about the new features!

Writing our own git

Yeah, we're going to write our own git, but don't worry, we'll not write our own VCS! We'll leave that up to Zach Oakes. Just the CLI interface this time and we'll let ourselves off the hook with println to fake the implementation. So here's a bit of code for you to look at. There's a bunch of functions like clone, log, checkout etc. that just print some info to stdout. The tree describes the command structure. And the dispatch call at the end dispatches the command line arguments over the tree.

#!/usr/bin/env bb
(require '[babashka.cli :as cli]
         '[clojure.string :as str])

;; stand-ins; a real tool would shell out to git
(def ^:private branches ["main" "develop" "feature/login" "release/2.0"])
(def ^:private remotes  ["origin" "upstream" "fork"])

(defn clone [{:keys [opts]}]
  (println "Cloning" (:url opts)
           (when (:depth opts) (str "(depth " (:depth opts) ")"))))

(defn log [{:keys [opts]}]
  (println "Showing" (or (:max-count opts) "all") (name (:format opts)) "log entries"))

(defn checkout [{:keys [opts]}]
  (println (if (:create opts) "Creating and switching to" "Switching to") (:branch opts)))

(defn remote-add [{:keys [opts]}]
  (println "Added remote" (:name opts) "->" (:url opts)))

(defn remote-remove [{:keys [opts]}]
  (println "Removed remote" (:name opts)))

(defn remote-list [_]
  (run! println remotes))

(def tree
  {:spec {:verbose {:coerce :boolean :desc "Be verbose" :alias :v}}
   :cmd
   {"clone"
    {:fn clone :doc "Clone a repository into a new directory"
     :spec {:url   {:desc "Repository to clone from" :require true}
            :depth {:desc "Create a shallow clone with N commits" :coerce :long}}
     :args->opts [:url]}
    "log"
    {:fn log :doc "Show commit logs"
     :spec {:format    {:desc "Output format" :coerce :keyword
                        :validate #{:oneline :short :full}
                        :default :short}
            :max-count {:desc "Limit the number of commits" :coerce :long :alias :n}}}
    "checkout"
    {:fn checkout :doc "Switch branches"
     :spec {:branch {:desc "Branch to switch to" :coerce :string
                     :complete-fn (fn [{:keys [to-complete]}]
                                    (filter #(str/starts-with? % to-complete) branches))
                     :require true}
            :create {:desc "Create the branch before switching" :coerce :boolean :alias :b}}
     :args->opts [:branch]}
    "remote"
    {:doc "Manage the set of tracked repositories"
     :cmd-order ["add" "remove" "list"]
     :cmd
     {"add"
      {:fn remote-add :doc "Add a remote"
       :spec {:name {:desc "Remote name" :require true}
              :url  {:desc "Remote URL" :require true}}
       :args->opts [:name :url]}
      "remove"
      {:fn remote-remove :doc "Remove a remote"
       :spec {:name {:desc "Remote name" :coerce :string
                     :complete-fn (fn [{:keys [to-complete]}]
                                    (filter #(str/starts-with? % to-complete) remotes))
                     :require true}}
       :args->opts [:name]}
      "list" {:fn remote-list :doc "List the existing remotes"}}}}
   :epilog "Docs: https://example.com/mygit"})

(defn -main [& args]
  (cli/dispatch tree args {:prog "mygit" :help true}))

(apply -main *command-line-args*)

The :prog value is used in help output and represents the program name. The :help true setting activates automatic help support. The automatic help support re-uses the already existing :desc (for options) /:doc (for commands) documentation values. When :validate is a set of keywords, auto-completion will pick up on this to autocomplete that option's value.

Save this code as mygit.clj and make it executable.

chmod +x mygit.clj

Note that at the time of writing, Babashka CLI version 0.11.73 isn't part of the newly released bb yet. This is coming soon, but there's more work to be done in babashka, to make babashka tasks even more awesome, which is going to be using part of the new CLI functionality. Stay tuned. For now you can add this snippet to the top of your code to make a bb script pick up on the newest CLI version:

(require '[babashka.deps :as deps])
(deps/add-deps '{:deps {org.babashka/cli {:mvn/version "0.11.73"}}})
(require '[babashka.cli] :reload)

Now we can invoke this script with ./mygit.clj. The usage line below will display mygit, because of the :prog setting, its display name, independent of how the script is invoked.

So let's invoke it in a couple of different ways:

$ ./mygit.clj clone https://example.com/repo.git --depth 1
Cloning https://example.com/repo.git (depth 1)

$ ./mygit.clj checkout -b feature/login
Creating and switching to feature/login

$ ./mygit.clj log -n 5 --format oneline
Showing 5 oneline log entries

$ ./mygit.clj remote add origin https://example.com/repo.git
Added remote origin -> https://example.com/repo.git

Automatic help

The :help true option to dispatch enriches the command tree with --help / -h options at every level, including the top level of the tree. It will also include a terse error message when invalid command line options are provided. So this is the opinionated help support that you can use as a good default, but don't have to use if you want to do your own thing. When --help/-h is invoked explicitly, the exit code will be 0 and help output is printed to stdout. On invalid input, output is printed to stderr and the exit code will be 1.

This is what top level help output looks like: ./mygit.clj --help:

Usage: mygit [options] <command>

Commands:
  clone    Clone a repository into a new directory
  log      Show commit logs
  checkout Switch branches
  remote   Manage the set of tracked repositories

Options:
  -v, --verbose  Be verbose
  -h, --help     Show this help

Run "mygit <command> --help" for more information on a command.

Docs: https://example.com/mygit

The per line description of a command comes from the :doc key, and the per line description of an option comes from the :desc key. Trailing prose can be provided via the :epilog key, which here is "Docs: https://example.com/mygit".

Every individual command also supports --help in a similar way:

Usage: mygit checkout [options] <branch>

Switch branches

Options:
      --branch  Branch to switch to (required)
  -b, --create  Create the branch before switching
  -h, --help    Show this help

Run "mygit --help" for global options.

Babashka CLI supports the :args->opts option to coalesce arguments into options. This is why we see <branch> printed as a supported argument. The (required) suffix comes from :require true in an option's spec and the short -b comes from the :alias setting.

Multi-word commands

In our git implementation (unlike the real one), the remote command does not invoke a function on its own. It just provides a :doc value, describing what the group of child commands are for.

Running ./mygit.clj remote --help lists the group's children:

Usage: mygit remote [options] <command>

Manage the set of tracked repositories

Commands:
  add    Add a remote
  remove Remove a remote
  list   List the existing remotes

Options:
  -h, --help  Show this help

Run "mygit remote <command> --help" for more information on a command.

Run "mygit --help" for global options.

Invoking ./mygit.clj remote add --help shows the help of remote add, with both positional arguments in the usage line:

Usage: mygit remote add [options] <name> <url>

Add a remote

Options:
      --name  Remote name (required)
      --url   Remote URL (required)
  -h, --help  Show this help

Run "mygit --help" for global options.

A mistyped or missing command gives a terse error and exits with exit code 1:

$ ./mygit.clj statys
Unknown command: statys

Commands:
  clone    Clone a repository into a new directory
  log      Show commit logs
  checkout Switch branches
  remote   Manage the set of tracked repositories

Run "mygit --help" for more information.

Shell completions

In Babashka CLI shell completions are produced dynamically by letting the shell call back into the CLI. As of today, Babashka CLI supports bash, zsh, fish, powershell and nushell.

We're just going to show here how to get completions for zsh but the process is very similar for other shells.

The ./mygit.clj org.babashka.cli/completions snippet --shell zsh invocation spits out a zsh snippet to stdout specific to this CLI. The org.babashka.cli/completions is inserted by Babashka CLI.

To enable completions in zsh (after compinit), run:

source <(./mygit.clj org.babashka.cli/completions snippet --shell zsh)

This enables completions for commands and options, showing descriptions on the side. Already used options are not suggested again, unless they are expected to be used multiple times.

$ ./mygit.clj remote <TAB>
add     -- Add a remote
remove  -- Remove a remote
list    -- List the existing remotes

The :validate set on log --format doubles as its completion source without adding extra config:

$ ./mygit.clj log --format <TAB>
full  oneline  short

Dynamic values can be supplied with :complete-fn. In our git example, branch names and remotes are completed by :complete-fn.

$ ./mygit.clj remote remove <TAB>
origin  upstream  fork
$ ./mygit.clj checkout <TAB>
main  develop  feature/login  release/2.0

To see what the completer returns without a shell, you can call the completions command directly:

$ ./mygit.clj org.babashka.cli/completions complete --shell zsh -- remote ''
add	Add a remote
remove	Remove a remote
list	List the existing remotes
--help	Show this help
-h	Show this help

Wrapping up

After holding off and thinking about these issues for a couple of years, I finally bit the bullet and added help and completion support to Babashka CLI. Hope you'll enjoy it!

More exciting related stuff is coming soon. The new Babashka CLI will be integrated into babashka of course, but also babashka tasks will be pimped with automatic help and completions. I'm not yet done with that work though.

Meanwhile I've been porting squint and neil over to the automatic help already.

A special shout-out to @lread for a ton of documentation review and improvements, and general maintenance. Thanks to @sohalt for the initial shell completions work back in 2024 that I picked up again for this release. Thanks to @plexus for his excellent Lambdaisland CLI talk at Babashka Conf 2026. Thanks also to Nextjournal whose commercial app I'm taking as a case study for this work, and last but not least to Clojurists Together and Sponsors on Github for giving me the time to work on this.

Published: 2026-06-18

Tagged: clojure cli babashka

Finding transitive var usages with clj-kondo

Today fellow Clojurian Søren Knudsen asked the following question on Clojurians Slack:

Say I'd like an overview of which fns in my Clojurescript app don't have :x metadata and aren't children of functions that have :x. I'd love this overview as data.

Anyone know a relevant analysis tool for this purpose?

Let's represent this problem in code form. Read it from bottom to top.

(defn grandchild [] ; no :x, but reachable via child: ignore
  :leaf)

(defn child [] ; no :x, called by ^:x grandparent: ignore
  (grandchild))

(defn ^:x grandparent [] ;; has :x metadata, ignore
  (child))

(defn standalone [] ;; has no :x metadata and not reachable from anything with :x metadata, include
  :other)

It turns out that clj-kondo analysis data is well suited to solve this problem. In this blog post, let's write a babashka script, that uses the clj-kondo pod. This bit of setup lets you do that. Of course you could also use clj-kondo as a regular JVM dependency, but we're going for ease here, since it's just a tiny script at this point.

#!/usr/bin/env bb
(require '[babashka.pods :as pods]
         '[clojure.set :as set])

(pods/load-pod 'clj-kondo/clj-kondo "2025.06.05")
(require '[pod.borkdude.clj-kondo :as clj-kondo])

Clj-kondo lets you find var-definitions and var-usages. Clj-kondo can also include var metadata. The arguments to clj-kondo's run! API function then should look like this:

(def analysis
  (-> (clj-kondo/run! {:lint ["src"]
                       :config {:analysis {:var-definitions {:meta [:x]}
                                           :var-usages true}}})
      :analysis))

To illustrate how it works, we'll introduce a multi-namespace project:

;; src/app/core.cljs
(ns app.core
  (:require [app.util :as util]))

(defn ^:x grandparent []
  (util/child))

(defn standalone []
  :other)

;; a top level var usage, not inside any var definition:
(util/child)
;; src/app/util.cljs
(ns app.util)

(defn grandchild []
  :leaf)

(defn child []
  (grandchild))

To illustrate what a var usage looks like in clj-kondo's analysis data, let's look at the usage in app.core of util/child:

{:from app.core
 :from-var grandparent
 :to app.util
 :name child
 ...}

The :from key describes from which namespace the reference was used. The :from-var key describes in which var definition the var was used, and this is the key ingredient of tracking transitive var usages. The :to + :name keys describe which var was used.

Vars

In clj-kondo's analysis you can request metadata from vars with :meta [:x] (or all metadata with true). To distinguish all project vars from those that have :x metadata we can do the following:

(defn fq [ns name] (symbol (str ns) (str name)))

(def defs (:var-definitions analysis))
(def project-vars (set (map #(fq (:ns %) (:name %)) defs)))
(def with-x (set (keep #(when (-> % :meta :x) (fq (:ns %) (:name %))) defs)))

Here project-vars is a set of symbols of all the project vars and with-x are only those that have :x metadata.

Building the call graph

Now we're ready to build the call graph that lets us solve our problem. In the following we're making a map that looks like: caller -> callees, but we limit callees only to project vars since we're not interested in vars like cljs.core/assoc, reagent.core/atom etc.

(def graph
  (reduce (fn [g {:keys [from from-var to name]}]
            (let [callee (fq to name)]
              (if (and from-var (contains? project-vars callee))
                (update g (fq from from-var) (fnil conj #{}) callee)
                g)))
          {}
          (:var-usages analysis)))

The from-var condition leaves out any top level var usages. The (contains? project-vars callee) takes care of filtering only on project vars. After running this, we'll end up with a graph (map) that looks like:

{app.core/grandparent #{app.util/child}
 app.util/child       #{app.util/grandchild}}

So app.core/grandparent calls app.util/child and app.util/child calls app.util/grandchild.

Transitive reachability

Next we write a function to find out what vars are reachable from a set of vars starts.

(defn reachable [starts]
  (loop [seen #{}
         todo (set starts)]
    (if (empty? todo)
      seen
      (let [seen (into seen todo)
            used-vars (set (mapcat graph todo))
            unvisited (set/difference used-vars seen)]
        (recur seen unvisited)))))

(def children (set/difference (reachable with-x) with-x))

(prn {:graph graph
      :with-x with-x
      :children-of-x children
      :without-x (set/difference project-vars with-x children)})

The reachable function just calculates the transitive closure of the graph, given a set of starting nodes (vars). The children var is the set of reachable vars without the starting points (the vars with :x metadata).

{:graph {app.core/grandparent #{app.util/child}
         app.util/child #{app.util/grandchild}}
 :with-x #{app.core/grandparent}
 :children-of-x #{app.util/child app.util/grandchild}
 :without-x #{app.core/standalone}}

So the answer we were looking for is #{app.core/standalone}. This function is neither a transitive child of any function with :x metadata, nor does it have any :x metadata itself.

The code

Here's the full script once again.

#!/usr/bin/env bb
(require '[babashka.pods :as pods]
         '[clojure.set :as set])

(pods/load-pod 'clj-kondo/clj-kondo "2025.06.05")
(require '[pod.borkdude.clj-kondo :as clj-kondo])

(def analysis
  (-> (clj-kondo/run! {:lint ["src"]
                       :config {:analysis {:var-definitions {:meta [:x]}
                                           :var-usages true}}})
      :analysis))

(defn fq [ns name] (symbol (str ns) (str name)))

(def defs (:var-definitions analysis))
(def project-vars (set (map #(fq (:ns %) (:name %)) defs)))
(def with-x (set (keep #(when (-> % :meta :x) (fq (:ns %) (:name %))) defs)))

;; caller -> callees, project vars only
(def graph
  (reduce (fn [g {:keys [from from-var to name]}]
            (let [callee (fq to name)]
              (if (and from-var (contains? project-vars callee))
                (update g (fq from from-var) (fnil conj #{}) callee)
                g)))
          {}
          (:var-usages analysis)))

(defn reachable [starts]
  (loop [seen #{} todo (set starts)]
    (if (empty? todo)
      seen
      (let [seen (into seen todo)
            used-vars (set (mapcat graph todo))
            unvisited (set/difference used-vars seen)]
        (recur seen unvisited)))))

(def children (set/difference (reachable with-x) with-x))

(prn {:graph graph
      :with-x with-x
      :children-of-x children
      :without-x (set/difference project-vars with-x children)})

Conclusion

I hope you learned how useful clj-kondo analysis data can be for tracking relations between vars and that you can use this data in casual babashka scripts as well!

Published: 2026-06-10

Tagged: clojure clj-kondo babashka

Archive