From 60511206a4adc229083a7985f0d19da4a5daa5dc Mon Sep 17 00:00:00 2001 From: Michiel Borkent Date: Fri, 24 Jul 2026 00:14:20 +0200 Subject: [PATCH 1/2] Defer a required :inherit option's check past its command --- CHANGELOG.md | 4 ++++ src/babashka/cli.cljc | 33 ++++++++++++++++++++------------- test/babashka/cli_test.cljc | 21 +++++++++++++++++++++ 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94410cd..92023b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ For breaking changes, check [here](#breaking-changes). [Babashka CLI](https://github.com/babashka/cli): turn Clojure functions into CLIs! +## Unreleased + +- A required `:inherit` option may be supplied after its command, not only before it + ## 0.12.80 - Add ordered `:enum` values for validation, help and completion diff --git a/src/babashka/cli.cljc b/src/babashka/cli.cljc index 312ff09..30dc2ae 100644 --- a/src/babashka/cli.cljc +++ b/src/babashka/cli.cljc @@ -532,19 +532,26 @@ :opts m} flag (assoc :flag flag))))))) (when require - (doseq [k require] - (when-not (find m k) - (let [flag (get opt->flag k) - arg (when (contains? positional k) (arg-label spec-map k))] - (error-fn (cond-> {:cause :require - :msg (if arg - (str "Required argument: " arg) - (str "Required option: " (flag-for k))) - :require require - :option k - :opts m} - arg (assoc :arg arg) - flag (assoc :flag flag))))))) + ;; in a dispatch tree, a leading command word after this level's options + ;; routes to a child, where an `:inherit` option can still be supplied + ;; (`group sub --opt v`). Defer its requirement to that child; the terminal + ;; level, which does not descend, enforces it. + (let [descending? (contains? (::dispatch-tree-ignored-args opts) + (first (-> (meta m) :org.babashka/cli :args)))] + (doseq [k require] + (when (and (not (find m k)) + (not (and descending? (:inherit (get spec-map k))))) + (let [flag (get opt->flag k) + arg (when (contains? positional k) (arg-label spec-map k))] + (error-fn (cond-> {:cause :require + :msg (if arg + (str "Required argument: " arg) + (str "Required option: " (flag-for k))) + :require require + :option k + :opts m} + arg (assoc :arg arg) + flag (assoc :flag flag)))))))) (when validate (doseq [[k vf] validate] (let [f (or (and diff --git a/test/babashka/cli_test.cljc b/test/babashka/cli_test.cljc index 76cb7c4..b761e20 100644 --- a/test/babashka/cli_test.cljc +++ b/test/babashka/cli_test.cljc @@ -1638,6 +1638,27 @@ (is (thrown-with-msg? #?(:cljd Object :default Exception) #"Unknown option: --registry" (cli/dispatch table ["deps" "outdated" "--registry" "X"] {:restrict true}))))) + (testing "a required :inherit option may be supplied on either side of the command" + (let [table [{:cmds ["deps"] :spec {:registry {:require true :inherit true}}} + {:cmds ["deps" "outdated"] :fn identity}]] + (testing "before the command" + (is (= {:registry "X"} (:opts (cli/dispatch table ["deps" "--registry" "X" "outdated"]))))) + (testing "after the command (require deferred to where it is supplied)" + (is (= {:registry "X"} (:opts (cli/dispatch table ["deps" "outdated" "--registry" "X"]))))) + (testing "still required when supplied nowhere" + (is (thrown-with-msg? + #?(:cljd Object :default Exception) #"Required option: --registry" + (cli/dispatch table ["deps" "outdated"])))) + (testing "required at the group itself (no command)" + (is (thrown-with-msg? + #?(:cljd Object :default Exception) #"Required option: --registry" + (cli/dispatch table ["deps"]))))) + (testing "a required option WITHOUT :inherit is still parent-only (errors after the command)" + (let [table [{:cmds ["deps"] :spec {:registry {:require true}}} + {:cmds ["deps" "outdated"] :fn identity}]] + (is (thrown-with-msg? + #?(:cljd Object :default Exception) #"Required option: --registry" + (cli/dispatch table ["deps" "outdated" "--registry" "X"])))))) (testing "dispatch-level :inherit makes options inherit without per-option marking" (let [table [{:cmds ["deps"] :spec {:registry {} :token {}}} {:cmds ["deps" "outdated"] :fn identity :spec {:format {}}}]] From 4c292201f915e868a6b86e993884334320df2934 Mon Sep 17 00:00:00 2001 From: Michiel Borkent Date: Fri, 24 Jul 2026 00:22:32 +0200 Subject: [PATCH 2/2] Test that a non-:inherit parent option's spec stays parent-only --- test/babashka/cli_test.cljc | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/babashka/cli_test.cljc b/test/babashka/cli_test.cljc index b761e20..7188e99 100644 --- a/test/babashka/cli_test.cljc +++ b/test/babashka/cli_test.cljc @@ -1638,6 +1638,22 @@ (is (thrown-with-msg? #?(:cljd Object :default Exception) #"Unknown option: --registry" (cli/dispatch table ["deps" "outdated" "--registry" "X"] {:restrict true}))))) + (testing "a non-:inherit parent option's spec does not reach the child after the command" + (let [table [{:cmds ["deps"] :spec {:registry {:coerce :keyword + :validate #{:a :b}}}} + {:cmds ["deps" "outdated"] :fn identity}]] + (testing "parent :coerce is not applied (value stays a raw string)" + (is (= {:registry "X"} + (:opts (cli/dispatch table ["deps" "outdated" "--registry" "X"]))))) + (testing "parent :validate is not applied (an invalid value is not rejected)" + (is (= {:registry "zzz"} + (:opts (cli/dispatch table ["deps" "outdated" "--registry" "zzz"]))))) + (testing "the same option before the command IS parsed by the parent spec" + (is (= {:registry :a} + (:opts (cli/dispatch table ["deps" "--registry" "a" "outdated"])))) + (is (thrown-with-msg? + #?(:cljd Object :default Exception) #"Invalid value for option --registry" + (cli/dispatch table ["deps" "--registry" "zzz" "outdated"])))))) (testing "a required :inherit option may be supplied on either side of the command" (let [table [{:cmds ["deps"] :spec {:registry {:require true :inherit true}}} {:cmds ["deps" "outdated"] :fn identity}]]