Skip to content

Task runner: :exec-fn / :cmd CLI tasks, via a vendored babashka/cli - #1084

Merged
yogthos merged 8 commits into
jolt-lang:mainfrom
burinc:feat/bb-1.13.223-parity
Sep 21, 2026
Merged

yogthos merged 8 commits into
jolt-lang:mainfrom
burinc:feat/bb-1.13.223-parity

Conversation

@burinc

@burinc burinc commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Closes #1083.

A bb.edn task that names an :exec-fn — or a :cmd tree of them — has its
arguments parsed by babashka.cli before anything runs, which is what gives it
coercion, validation, subcommands and --help. That is babashka's CLI-task
feature, and this brings it to jolt at 1.13.219 + 1.13.221 + 1.13.223
semantics.

The PR was opened as direction-seeking, with two commits and the question
"do you want a vendored babashka/cli in this tree?" (#1083). The answer is
yes, and the remaining nine tasks of the plan are now here.

What landed

  • vendor/clibabashka/cli at v0.12.91, registered on
    ldr-install-roots plus the four other places a vendored source root has to
    appear (TESTBIN-INPUTS, three set-source-roots!* sites).
  • stdlib/jolt/cli.clj — the public face, following stdlib/jolt/process.clj:
    (import-vars babashka.cli :exclude #{*exit-fn*}). Of the 24 public vars,
    *exit-fn* is the only one import-vars cannot re-export, because it is
    dynamic and a delegating defn wrapper would break binding.
  • jolt.taskscli-node, task-node, cli-dispatch! and run-cli-dep!,
    keeping babashka's names so babashka.impl.tasks and this can be read side by
    side. babashka assembles a program string for SCI; jolt evaluates the task map
    directly, so the same semantics are wired as ordinary function calls.
  • jolt.completions + jolt.main — the hidden
    org.babashka.cli/completions callback, and zsh/bash/fish snippets that use
    it for CLI tasks only.
  • README + llms.txt — "Tasks that parse their arguments".

The semantics, measured against babashka

Every row below was run against real bb on the same bb.edn, not assumed.
jolt <task> --help and bb <task> --help are byte-identical apart from the
program name, inherited options included.

behaviour source
:exec-args on the task as well as under :cli, the bare key winning 1.13.220
a task's :cli :exec-args merge over the :tasks top-level, not replace 1.13.220
a task's :cli spec adds to the runner-level spec; --help lists the rest under Inherited options 1.13.220
a task with :exec-fn runs when another task :depends on it 1.13.220
a dep's options parse for the CLI task that runs, and show in its --help 1.13.220
a CLI task cannot name a :cmd task in :depends unless it also has a :task body 1.13.220
:cmd may be a symbol naming a var holding the tree; its namespace loads on demand 1.13.220
completion offers inherited options too 1.13.220
tasks and --help still describe a task when a dependency's namespace will not load 1.13.220
a :depends :exec-fn receives all parsed options, runner-level :cli defaults included 1.13.221
with :restrict, only the options it declares 1.13.221
a :depends :restrict falls back to the runner-level one 1.13.221
a :depends :exec-fn receives its own :exec-args and spec defaults, command line winning; they do not apply to the target #2103
an :exec-fn task in the :depends of a task with a :task body runs, with its own :exec-args and spec defaults #2151, 1.13.223

The three rows the installed bb (1.13.220) answers differently are exactly the
1.13.221 / 1.13.223 changes, and jolt takes the newer side of each — checked
against babashka.impl.tasks at 1.13.223 rather than against the older binary.

--help short-circuits before the :depends walk: the body, the dep walk and
the :enter/:leave pair all go in as thunks the parser calls only once it has
picked a command, so asking what a task accepts runs nothing. Hook placement,
the diamond (a shared CLI dep runs once), --parallel, and
babashka.tasks/run reaching a CLI task were each checked against bb too.

Two deliberate differences

  • Errors are plain ex-info, not {:babashka/exit 1}. In jolt that key
    means "exit with this status, the failure has already reported itself", which
    is right for a failed subprocess and wrong for a mistake in a bb.edn — it
    would make an unresolvable :exec-fn exit 1 in silence. A bad task map is
    reported the way jolt already reports unknown command or task. Exit codes
    match babashka's.
  • No docstring fallback for a task's :doc in jolt tasks and completion.
    Both read the project's config files alone on jolt, and deriving that doc
    means loading the handler's namespace — a completing shell must not be what
    discovers your deps do not resolve. A docstring still reaches --help, where
    the fn is being resolved anyway.

Completion costs a plain task nothing

jolt completions tasks marks a task that parses with a third cli field, so
the snippets call jolt back only for those. What such a task accepts depends on
where the cursor is and cannot be cached as a flat list; every other task still
completes from the cache without starting jolt at all.

Gates

clishim (new, in CI-GATES), taskssmoke 102, completionssmoke 40,
stdlibfasl 11, documented, deadhost, portcheck, depssmoke,
scriptsmoke, plus the full CI matrix. Fixture: test/chez/tasks/cliproj.

No gate was added for the submodule itself: vendor/fs and vendor/process are
plain submodules with none, and grenadinecheck exists only to reconcile
grenadine's separate generated-sources tree, which cli has no equivalent of.

Relationship to #878

#878 shipped jolt completions in v0.8.5, completing task names, and said
per-task argument completion was out of reach because "jolt has no per-task
option metadata today". This closes that gap without changing what #878 shipped.

burinc and others added 7 commits September 22, 2026 01:22
host/chez/stdlib-fasl-manifest.txt pins the exact set of install-owned stdlib
namespaces that get compiled to fasls and embedded. Adding vendor/cli/src and
stdlib/jolt/cli.clj introduced babashka.cli, babashka.cli.exec,
babashka.cli.internal and jolt.cli, so the discovered set became 83 against a
manifest of 79 and build-jolt.ss failed with manifest drift.

The drift check is strict by design and names the required set, so this is the
edit it asks for. Verified by running the packaged build end to end and the
smoke the flake workflow runs.
A bb.edn task could only be a body to run. babashka's task runner also
takes a task that names a HANDLER (:exec-fn) or a tree of them (:cmd),
and parses the command line for it before anything runs -- which is what
gives such a task coercion, validation, subcommands and --help without
the project writing a parser. That is what jolt-lang#878 said was out of reach
("jolt has no per-task option metadata today"), and the vendored
babashka/cli is what puts it in reach.

The semantics are babashka 1.13.219 + 1.13.221 + 1.13.223, and the
reference is babashka.impl.tasks at that version. babashka assembles a
program STRING and hands it to SCI, so its cli-node / -task-node /
-cli-dispatch / -run-cli-dep are code generators; jolt evaluates the task
map directly, so the same four shapes are ordinary function calls here.
The names are kept so the two can be read side by side.

What a task map may now say:

  serve {:exec-fn app/serve :cli {:spec {:port {:coerce :long}}}}
  db    {:cmd {"migrate" {:exec-fn app/migrate} "seed" {:exec-fn app/seed}}}

:cli holds everything babashka.cli takes, so reading a task map tells you
which keys are jolt's and which are the parser's, and it may name a def
instead of a literal map -- which is where options edn cannot express
live, such as an :error-fn. :cmd may name a def too, for a large tree.
:exec-args may sit on the task as well as under :cli, the task's winning.
A spec may live on the handler var as :org.babashka/cli metadata, which
is where `bb -x` reads it from, and the var's docstring becomes the
node's :doc.

Two things about the ORDER, which is the part that is easy to get wrong:

  * --help must not run the task. So the :depends walk, the body and the
    :enter/:leave pair all go into the dispatch as thunks, and the parser
    decides whether any of them is called. Asking what a task accepts now
    runs none of its dependencies, where before the walk happened first
    and the arguments were never looked at.

  * A CLI task named in :depends does not parse on its own (1.13.221).
    Its handler is called in its own place in the graph with the options
    the TARGET's parse produced -- only the ones actually supplied, over
    its own and the runner-level defaults -- and :restrict narrows that
    to the keys it declares. Its spec merges into the target's dispatch
    spec, so those options parse at the target and print in its --help.
    Naming a :cmd task in :depends is refused: a tree of commands has no
    single handler to run, and picking one for the project would be a
    guess.

Errors here are plain ex-info, not babashka's {:babashka/exit 1}: in jolt
that key means "exit with this status, the failure has already reported
itself", which is right for a failed subprocess and wrong for a typo in a
bb.edn -- it would make an unresolvable :exec-fn exit 1 in silence.

One babashka behaviour is deliberately not here. It falls back to a
handler's DOCSTRING for a task with no :doc, in `bb tasks` and in
completion. Both read the project's config files alone on jolt, and
deriving that doc means loading the handler's namespace -- which is
exactly what a listing and a completing shell must not do. The docstring
still reaches --help, where the fn is being resolved anyway.

babashka.cli is required on first use, like babashka.tasks beside it: a
:tasks map that names no :exec-fn and no :cmd parses nothing, and loading
2,500 lines of parser for a task that only shells out is what the lazy
require in this namespace exists to avoid.

Tests: test/chez/tasks/cliproj, a fixture whose handlers print the map
they were called with, and 33 rows in tasks-smoke over it -- the parse
and its defaults, :exec-args precedence, a spec on the var, :cli and :cmd
by symbol, --help and -h, a :cmd tree and a tree with a body as its root,
the :depends handling in both directions, the :cmd-in-:depends refusal, a
runner-level :cli, a throwing handler, and `(run 'task)` reaching a CLI
task from a body. `make taskssmoke` is 102 passed, 0 failed.
jolt's completion design is a split by how often a thing changes: its own
commands are baked into the generated snippet, and a project's tasks are
fetched once with `jolt completions tasks` and cached against the mtimes
of deps.edn and bb.edn. A warm press reads a small file and jolt does not
run, which matters because jolt's floor is ~0.22s and a quarter-second
press feels broken.

A task that PARSES its arguments cannot be cached that way: what it
accepts depends on where the cursor is. babashka answers this by calling
its binary back on every press, through a hidden
`org.babashka.cli/completions` command that babashka.cli's own stubs
know. jolt now answers that same command -- but only for the tasks that
need it:

  * `completions tasks` gains a third field, `cli`, on a task with an
    :exec-fn or a :cmd (and an empty doc field, so field 3 stays field 3).
  * the zsh, bash and fish snippets read that marker. After a task name
    they call back only when the task carries it; every other task falls
    through to the shell's own file completion exactly as before, forking
    nothing.
  * jolt.main answers `org.babashka.cli/completions complete --shell SHELL
    -- <task> <token>...` by building the task's dispatch tree and handing
    the tokens to babashka.cli, and `... snippet` by printing jolt's own
    snippet, since that is the one that knows how to call back.

Nothing on that path may report a failure. A completing shell discards
stderr and reads stdout as the candidate list, so a stale bb.edn or a
project whose deps don't resolve has to come back as babashka.cli's
file-completion marker: an error there would offer nothing AND suppress
the shell's fallback, which looks like completion being broken rather
than like the project being broken. The project is applied, the task's
:requires run and the tree is built inside that guard, with stdout muted
so a namespace that prints on load cannot become a candidate.

The zsh snippet's _jolt_add also had to learn that a line may have three
fields: it took everything after the first tab as the description, which
with a marker present would have rendered as "a doc<TAB>cli".

Tests: 14 rows in completions-smoke -- the marker in the task lines, the
callback itself (options, subcommands, and the file-completion answer for
a plain task, an unknown task and a project with no tasks), the bash
function actually completing a CLI task's options and subcommands, and
the two spawn-count rows that are the whole point: a press after a plain
task starts jolt not at all, and after a task that parses, exactly once.
`make completionssmoke` is 40 passed, 0 failed.
test/cli_shim_test.clj was added with the vendoring but nothing ran it:
no Makefile target, no CI-GATES entry, so it could rot silently. It pins
what jolt.cli re-exports -- that the surface reaches the parser, the
dispatcher and the help renderer -- and that *exit-fn* is NOT among them,
which is deliberate: it is a dynamic var, and import-vars can only
re-export a delegating fn.

`make clishim`, in CI-GATES next to the other offline source-mode gates.
The task runner's own use of babashka.cli is covered by taskssmoke.
The README had no home for the task runner beyond passing references, so
this adds the section the feature needs: what an :exec-fn / :cmd task map
looks like, what --help does and what it does not do (run the task's
:depends), how a CLI task in :depends is handled, and that the parser is
vendored at vendor/cli and re-exported as jolt.cli for a program to use
directly.

Shell completion gains the paragraph for the third `cli` field and the
one case that does call jolt back, and llms.txt records both the field
and the hidden callback command.
@yogthos

yogthos commented Sep 21, 2026

Copy link
Copy Markdown
Member

Picked this up and pushed the rest of the plan to this branch — the nine
remaining tasks are done, so the draft is no longer a dependency with no feature
attached. Four commits on top of your three:

tasks: dispatch an :exec-fn / :cmd task through babashka.cli
The runner itself. cli-node / -task-node / -cli-dispatch / -run-cli-dep
ported from babashka.impl.tasks at 1.13.223, as ordinary calls rather than
assembled program strings — jolt evaluates the task map directly, so there is no
SCI layer to generate code for. :exec-fn, :cmd (literal or a symbol naming a
def), :cli (map or symbol), :exec-args with the task's winning over :cli's,
a spec on the handler var as :org.babashka/cli metadata, and a runner-level
:tasks {:cli ...}.

Two ordering points, which are the parts worth reviewing:

  • --help runs nothing. The :depends walk, the body and the :enter/:leave
    pair all go in as thunks and the parser decides whether any is called.
  • 1.13.221 / babashka#2151 :depends: a CLI dep does not parse; its handler is
    called in its place in the graph with the options the target's parse supplied,
    over its own and the runner-level defaults, narrowed by :restrict. Its spec
    merges into the target's, so the option parses there and shows in --help.
    :depends naming a :cmd task is refused.

Errors are plain ex-info, not babashka's {:babashka/exit 1} — in jolt that
key means "exit with this status, the failure already reported itself", which
would make an unresolvable :exec-fn exit 1 in silence.

completions: complete a CLI task's own options, without spending a start
jolt.main now answers the hidden org.babashka.cli/completions command. The
snippets do not call it blindly: completions tasks gained a third cli field,
and zsh/bash/fish call back only for a task carrying it, so a plain task still
completes with no jolt process at all. Two of the smoke rows assert exactly that
by counting spawns.

Run the jolt.cli shim test as a gate
test/cli_shim_test.clj was in the branch but nothing ran it — no target, no
CI-GATES entry. It is make clishim now.

Document tasks that parse their arguments — a README section (there was no
home for the task runner beyond passing references), plus the completion field
and the callback command in llms.txt.

Your manifest fix landed while I was working on the same thing; I dropped my
duplicate in the rebase, so c62362ce is the one that survives.

One deliberate divergence from babashka, noted in the source: bb falls back to a
handler's docstring for a task with no :doc in bb tasks and in completion.
Both read config files alone here, and deriving that doc means loading the
handler's namespace — which is the one thing a listing and a completing shell
must not do. The docstring still reaches --help.

Gates run locally against the built binary: taskssmoke 102/0 (33 new rows over
a new test/chez/tasks/cliproj fixture), completionssmoke 40/0 (14 new),
scriptsmoke 33/0, stdlibfasl 11/0, clishim, documented, unit. No seed
re-mint is needed — emit-image.ss embeds only the compiler namespaces and
clojure.core, not jolt.tasks.

Taking it out of draft on that basis; say the word if you would rather keep
iterating on it first.

@yogthos
yogthos marked this pull request as ready for review September 21, 2026 17:50
@yogthos yogthos changed the title Task runner: vendor babashka/cli as groundwork for :exec-fn / :cmd CLI tasks (draft, see #1083) Task runner: :exec-fn / :cmd CLI tasks, via a vendored babashka/cli Sep 21, 2026
@yogthos

yogthos commented Sep 21, 2026

Copy link
Copy Markdown
Member

Answering #1083: yes — the vendored babashka/cli is wanted, and this is the shape we want it in. Reviewed and taking it.

What I did to the branch:

What I checked, beyond the gates: every semantic row against real bb on the same bb.edn, rather than against the changelog. jolt <task> --help and bb <task> --help come out byte-identical apart from the program name, Inherited options included, and so does the completion callback's output. Hook placement, the diamond, --parallel, a two-level CLI dependency chain, :override-builtin on a CLI task, and a dependency whose handler namespace does not load all agree.

The three rows where the installed bb (1.13.220) disagrees are exactly the 1.13.221 and #2151 changes — a :depends handler getting all parsed options rather than only its own, :restrict keys including the runner level, and a CLI task running under a target that has a :task body. This branch takes the newer side of each, which I verified against babashka.impl.tasks at 1.13.223 rather than against the older binary. That is the right call.

Local gates on the merged tree: clishim, taskssmoke 102, completionssmoke 40, stdlibfasl 11, documented, deadhost, portcheck, depssmoke 138, scriptsmoke 33.

One follow-up filed rather than fixed here: babashka.tasks/exec is still missing, so a task body cannot call (exec 'app.api/build). The cli-node docstring's "which is what an exec call already accepts" is true of babashka and not yet of jolt; both are on the bead, since the comment becomes true when exec lands.

Thanks for opening it as a question first — that was the right way to do it.

@yogthos
yogthos merged commit 923ad46 into jolt-lang:main Sep 21, 2026
5 checks passed
@burinc
burinc deleted the feat/bb-1.13.223-parity branch September 21, 2026 22:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Task runner: would you accept :exec-fn / :cmd CLI tasks, via a vendored babashka/cli?

2 participants