From 87ba2ee78981f3d7906438e2423df05371303797 Mon Sep 17 00:00:00 2001 From: Michiel Borkent Date: Tue, 4 Aug 2026 11:21:39 +0200 Subject: [PATCH 1/4] Add :inherited option for options a command accepts on another command behalf --- src/babashka/cli.cljc | 43 +++++++++++++++++++++++++------------ test/babashka/cli_test.cljc | 26 ++++++++++++++++++++++ 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/src/babashka/cli.cljc b/src/babashka/cli.cljc index 333f482..87ddf71 100644 --- a/src/babashka/cli.cljc +++ b/src/babashka/cli.cljc @@ -1857,9 +1857,13 @@ $env.config.completions.external.completer = {|spans| ancestors) and the `:parents` pointers (ancestors with non-inherited options that must precede the command). + `inherited-extra` is a spec the caller declares usable here without it living + on an ancestor, for options this command accepts on another command's behalf. + Ancestors win over it, and the node's own spec wins over both. + Specs are mapified here for set reasoning (a standalone `format-command-help` spec may be a vec-of-pairs); display order is handled by `render-help`." - [tree cmds prog inherit] + [tree cmds prog inherit inherited-extra] (let [node-at (fn [path] (get-in tree (interleave (repeat :cmd) path))) prog-at (fn [path] (str/join " " (cons prog path))) ;; options available at the target level itself (e.g. an injected --help, @@ -1872,7 +1876,7 @@ $env.config.completions.external.completer = {|spans| spec (->spec-map (:spec (node-at pre))) inh (inherited-entries spec inherit)]] {:pre pre :inh inh :own (apply dissoc spec (keys inh))}) - inherited (reduce merge {} (map :inh ancestors)) + inherited (reduce merge (->spec-map inherited-extra) (map :inh ancestors)) ;; ancestors with non-inherited options that aren't also available here ;; (those must be given before the command) parents (for [{:keys [pre own]} ancestors @@ -1916,6 +1920,11 @@ $env.config.completions.external.completer = {|spans| * `:inherit` - only needed when you pass a dispatch-level `:inherit` to `dispatch`; pass the same value so `Inherited options:` matches. Per-option `:inherit true` is detected automatically. + * `:inherited` - a spec of options this command accepts that are declared + elsewhere, listed under `Inherited options:`. For commands that + parse on another command's behalf, where there is no ancestor to + carry them. Ancestors win over it, the command's own spec wins + over both. Options are listed in the entry's `:order` when it has one, else in spec order (a vec-of-pairs `:spec` keeps its order; a map follows key order, unreliable @@ -1924,9 +1933,9 @@ $env.config.completions.external.completer = {|spans| This is the renderer the `:help` option uses; call it from a custom `:help-fn` to render the standard help and then add your own output. An entry may carry `:no-doc true` to be omitted from `Commands:`." - [{:keys [table cmds prog inherit] :or {cmds []}}] + [{:keys [table cmds prog inherit inherited] :or {cmds []}}] (let [tree (table->tree table) - ctx (command-help-context tree (vec cmds) prog inherit)] + ctx (command-help-context tree (vec cmds) prog inherit inherited)] (render-help (:node ctx) ctx))) (defn ^:dynamic *exit-fn* @@ -1965,8 +1974,9 @@ $env.config.completions.external.completer = {|spans| caller does not exit (it returns like a normal `:fn`, so the process ends with status 0). Reads the command tree, `:prog` and `:inherit` from the data dispatch threads in; renders via [[format-command-help]]." - [{:keys [tree dispatch prog inherit]}] - (println (format-command-help {:table tree :cmds (or dispatch []) :prog prog :inherit inherit}))) + [{:keys [tree dispatch prog inherit inherited]}] + (println (format-command-help {:table tree :cmds (or dispatch []) :prog prog + :inherit inherit :inherited inherited}))) (defn- dispatch-error-msg "The terse one-line `:msg` for a command-level dispatch error, or nil for an @@ -1994,10 +2004,10 @@ $env.config.completions.external.completer = {|spans| this, then calls [[*exit-fn*]]). Call it from a custom `:error-fn` to keep the standard message and add your own output. `--help`/`-h` is not an error - it goes to the `:help-fn`, rendered by [[format-command-help]]." - [{:keys [cause dispatch wrong-input msg prog inherit tree]}] + [{:keys [cause dispatch wrong-input msg prog inherit inherited tree]}] (let [tree (table->tree tree) path (or dispatch []) - ctx-at (fn [p] (command-help-context tree (vec p) prog inherit)) + ctx-at (fn [p] (command-help-context tree (vec p) prog inherit inherited)) hint (str "Run \"" (str/join " " (cons prog path)) " --help\" for more information.") usage (fn [p] @@ -2032,12 +2042,14 @@ $env.config.completions.external.completer = {|spans| (eprintln (format-command-error data))) (defn- thread-dispatch-context - "Add the dispatch-level `:prog` and `:inherit` (when set) to error/help `data`, - so an `:error-fn` / `:help-fn` can render without being handed them." - [data {:keys [prog inherit]}] + "Add the dispatch-level `:prog`, `:inherit` and `:inherited` (when set) to + error/help `data`, so an `:error-fn` / `:help-fn` can render without being + handed them." + [data {:keys [prog inherit inherited]}] (cond-> data - prog (assoc :prog prog) - inherit (assoc :inherit inherit))) + prog (assoc :prog prog) + inherit (assoc :inherit inherit) + inherited (assoc :inherited inherited))) ;; command names to suggest in errors: skip `:no-doc`, same as help and ;; completion hide them @@ -2048,7 +2060,10 @@ $env.config.completions.external.completer = {|spans| ([tree args] (dispatch-tree' tree args nil)) ([tree args opts] - (loop [cmds [] all-opts {} args args cmd-info tree inherited {}] + ;; `:inherited` seeds the same accumulator ancestors use: options the caller + ;; declares acceptable here without them living on an ancestor node + (loop [cmds [] all-opts {} args args cmd-info tree + inherited (or (->spec-map (:inherited opts)) {})] (let [kwm cmd-info ;; capture before the parse-args destructure below shadows `opts` inherit-opt (:inherit opts) diff --git a/test/babashka/cli_test.cljc b/test/babashka/cli_test.cljc index 05df5a8..962151e 100644 --- a/test/babashka/cli_test.cljc +++ b/test/babashka/cli_test.cljc @@ -682,6 +682,19 @@ (when-not (::exit (ex-data e)) (throw e))))))] {:out out :exit @exit})) +(deftest dispatch-inherited-test + (let [tree {:spec {:bar {:coerce :long}} :restrict true :fn identity}] + (testing "an :inherited option parses even though the node does not declare it" + (is (submap? {:opts {:foo 1}} + (cli/dispatch tree ["--foo" "1"] {:inherited {:foo {:coerce :long}}})))) + (testing "the node's own options still parse" + (is (submap? {:opts {:bar 2}} + (cli/dispatch tree ["--bar" "2"] {:inherited {:foo {:coerce :long}}})))) + (testing ":restrict still rejects an option neither declares" + (is (thrown-with-msg? + #?(:clj Exception :cljs js/Error) #"Unknown option: --nope" + (cli/dispatch tree ["--nope" "1"] {:inherited {:foo {:coerce :long}}})))))) + (deftest dispatch-tree-input-test ;; dispatch accepts a tree (the table->tree shape) directly (let [tree {:doc "tool" @@ -1148,6 +1161,19 @@ (is (= (str "Usage: p sub [options]\n\n" "Options:\n --x local x") (cli/format-command-help {:table t :cmds ["sub"] :prog "p"}))))) + (testing ":inherited lists options declared elsewhere, with no ancestor to carry them" + (is (= (str "Usage: p [options]\n\n" + "Options:\n --bar own\n\n" + "Inherited options:\n --foo from elsewhere") + (cli/format-command-help {:table {:spec {:bar {:desc "own"}}} + :prog "p" + :inherited {:foo {:desc "from elsewhere"}}}))) + (testing "the command's own spec wins over it" + (is (= (str "Usage: p [options]\n\n" + "Options:\n --bar own") + (cli/format-command-help {:table {:spec {:bar {:desc "own"}}} + :prog "p" + :inherited {:bar {:desc "from elsewhere"}}}))))) (testing ":args->opts renders labeled positionals in the usage line" (let [t [{:cmds ["copy"] :fn identity :doc "Copy" :args->opts [:src :dest] :spec {:force {:desc "Force"}}}]] From 713b86875ebd7a0f10b125a143e0565d46ff995b Mon Sep 17 00:00:00 2001 From: Michiel Borkent Date: Tue, 4 Aug 2026 11:37:20 +0200 Subject: [PATCH 2/4] Use the cljd-aware reader conditional in the :inherited test --- test/babashka/cli_test.cljc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/babashka/cli_test.cljc b/test/babashka/cli_test.cljc index 962151e..8d61d30 100644 --- a/test/babashka/cli_test.cljc +++ b/test/babashka/cli_test.cljc @@ -692,7 +692,7 @@ (cli/dispatch tree ["--bar" "2"] {:inherited {:foo {:coerce :long}}})))) (testing ":restrict still rejects an option neither declares" (is (thrown-with-msg? - #?(:clj Exception :cljs js/Error) #"Unknown option: --nope" + #?(:cljd Object :default Exception) #"Unknown option: --nope" (cli/dispatch tree ["--nope" "1"] {:inherited {:foo {:coerce :long}}})))))) (deftest dispatch-tree-input-test From c92731e219d524c6c3955bbd479742d4929e17b3 Mon Sep 17 00:00:00 2001 From: Michiel Borkent Date: Tue, 4 Aug 2026 12:11:22 +0200 Subject: [PATCH 3/4] Render the dispatch-level spec in help instead of hiding it --- src/babashka/cli.cljc | 48 +++++++++++++++++-------------------- test/babashka/cli_test.cljc | 23 +++++++++--------- 2 files changed, 33 insertions(+), 38 deletions(-) diff --git a/src/babashka/cli.cljc b/src/babashka/cli.cljc index 87ddf71..cbd0664 100644 --- a/src/babashka/cli.cljc +++ b/src/babashka/cli.cljc @@ -1857,13 +1857,13 @@ $env.config.completions.external.completer = {|spans| ancestors) and the `:parents` pointers (ancestors with non-inherited options that must precede the command). - `inherited-extra` is a spec the caller declares usable here without it living - on an ancestor, for options this command accepts on another command's behalf. - Ancestors win over it, and the node's own spec wins over both. + `global-spec` is the dispatch-level `:spec`: options accepted at every level, + which have no ancestor node to be listed under. Ancestors win over it, and the + node's own spec wins over both. Specs are mapified here for set reasoning (a standalone `format-command-help` spec may be a vec-of-pairs); display order is handled by `render-help`." - [tree cmds prog inherit inherited-extra] + [tree cmds prog inherit global-spec] (let [node-at (fn [path] (get-in tree (interleave (repeat :cmd) path))) prog-at (fn [path] (str/join " " (cons prog path))) ;; options available at the target level itself (e.g. an injected --help, @@ -1876,7 +1876,7 @@ $env.config.completions.external.completer = {|spans| spec (->spec-map (:spec (node-at pre))) inh (inherited-entries spec inherit)]] {:pre pre :inh inh :own (apply dissoc spec (keys inh))}) - inherited (reduce merge (->spec-map inherited-extra) (map :inh ancestors)) + inherited (reduce merge (->spec-map global-spec) (map :inh ancestors)) ;; ancestors with non-inherited options that aren't also available here ;; (those must be given before the command) parents (for [{:keys [pre own]} ancestors @@ -1920,11 +1920,9 @@ $env.config.completions.external.completer = {|spans| * `:inherit` - only needed when you pass a dispatch-level `:inherit` to `dispatch`; pass the same value so `Inherited options:` matches. Per-option `:inherit true` is detected automatically. - * `:inherited` - a spec of options this command accepts that are declared - elsewhere, listed under `Inherited options:`. For commands that - parse on another command's behalf, where there is no ancestor to - carry them. Ancestors win over it, the command's own spec wins - over both. + * `:spec` - the dispatch-level spec, when you pass one to `dispatch`: + options accepted at every level, listed under + `Inherited options:` since no ancestor node declares them. Options are listed in the entry's `:order` when it has one, else in spec order (a vec-of-pairs `:spec` keeps its order; a map follows key order, unreliable @@ -1933,9 +1931,9 @@ $env.config.completions.external.completer = {|spans| This is the renderer the `:help` option uses; call it from a custom `:help-fn` to render the standard help and then add your own output. An entry may carry `:no-doc true` to be omitted from `Commands:`." - [{:keys [table cmds prog inherit inherited] :or {cmds []}}] + [{:keys [table cmds prog inherit spec] :or {cmds []}}] (let [tree (table->tree table) - ctx (command-help-context tree (vec cmds) prog inherit inherited)] + ctx (command-help-context tree (vec cmds) prog inherit spec)] (render-help (:node ctx) ctx))) (defn ^:dynamic *exit-fn* @@ -1974,9 +1972,9 @@ $env.config.completions.external.completer = {|spans| caller does not exit (it returns like a normal `:fn`, so the process ends with status 0). Reads the command tree, `:prog` and `:inherit` from the data dispatch threads in; renders via [[format-command-help]]." - [{:keys [tree dispatch prog inherit inherited]}] + [{:keys [tree dispatch prog inherit] :as data}] (println (format-command-help {:table tree :cmds (or dispatch []) :prog prog - :inherit inherit :inherited inherited}))) + :inherit inherit :spec (::global-spec data)}))) (defn- dispatch-error-msg "The terse one-line `:msg` for a command-level dispatch error, or nil for an @@ -2004,10 +2002,10 @@ $env.config.completions.external.completer = {|spans| this, then calls [[*exit-fn*]]). Call it from a custom `:error-fn` to keep the standard message and add your own output. `--help`/`-h` is not an error - it goes to the `:help-fn`, rendered by [[format-command-help]]." - [{:keys [cause dispatch wrong-input msg prog inherit inherited tree]}] + [{:keys [cause dispatch wrong-input msg prog inherit tree] :as data}] (let [tree (table->tree tree) path (or dispatch []) - ctx-at (fn [p] (command-help-context tree (vec p) prog inherit inherited)) + ctx-at (fn [p] (command-help-context tree (vec p) prog inherit (::global-spec data))) hint (str "Run \"" (str/join " " (cons prog path)) " --help\" for more information.") usage (fn [p] @@ -2042,14 +2040,15 @@ $env.config.completions.external.completer = {|spans| (eprintln (format-command-error data))) (defn- thread-dispatch-context - "Add the dispatch-level `:prog`, `:inherit` and `:inherited` (when set) to + "Add the dispatch-level `:prog`, `:inherit` and `:spec` (when set) to error/help `data`, so an `:error-fn` / `:help-fn` can render without being - handed them." - [data {:keys [prog inherit inherited]}] + handed them. The spec goes under a namespaced key: option-error data already + carries the parser's own `:spec`." + [data {:keys [prog inherit spec]}] (cond-> data - prog (assoc :prog prog) - inherit (assoc :inherit inherit) - inherited (assoc :inherited inherited))) + prog (assoc :prog prog) + inherit (assoc :inherit inherit) + spec (assoc ::global-spec spec))) ;; command names to suggest in errors: skip `:no-doc`, same as help and ;; completion hide them @@ -2060,10 +2059,7 @@ $env.config.completions.external.completer = {|spans| ([tree args] (dispatch-tree' tree args nil)) ([tree args opts] - ;; `:inherited` seeds the same accumulator ancestors use: options the caller - ;; declares acceptable here without them living on an ancestor node - (loop [cmds [] all-opts {} args args cmd-info tree - inherited (or (->spec-map (:inherited opts)) {})] + (loop [cmds [] all-opts {} args args cmd-info tree inherited {}] (let [kwm cmd-info ;; capture before the parse-args destructure below shadows `opts` inherit-opt (:inherit opts) diff --git a/test/babashka/cli_test.cljc b/test/babashka/cli_test.cljc index 8d61d30..f71f4ee 100644 --- a/test/babashka/cli_test.cljc +++ b/test/babashka/cli_test.cljc @@ -682,18 +682,17 @@ (when-not (::exit (ex-data e)) (throw e))))))] {:out out :exit @exit})) -(deftest dispatch-inherited-test - (let [tree {:spec {:bar {:coerce :long}} :restrict true :fn identity}] - (testing "an :inherited option parses even though the node does not declare it" - (is (submap? {:opts {:foo 1}} - (cli/dispatch tree ["--foo" "1"] {:inherited {:foo {:coerce :long}}})))) +(deftest dispatch-global-spec-test + (let [tree {:spec {:bar {:coerce :long}} :restrict true :fn identity} + global {:spec {:foo {:coerce :long :desc "global foo"}}}] + (testing "a dispatch-level :spec option parses though the node does not declare it" + (is (submap? {:opts {:foo 1}} (cli/dispatch tree ["--foo" "1"] global)))) (testing "the node's own options still parse" - (is (submap? {:opts {:bar 2}} - (cli/dispatch tree ["--bar" "2"] {:inherited {:foo {:coerce :long}}})))) + (is (submap? {:opts {:bar 2}} (cli/dispatch tree ["--bar" "2"] global)))) (testing ":restrict still rejects an option neither declares" (is (thrown-with-msg? #?(:cljd Object :default Exception) #"Unknown option: --nope" - (cli/dispatch tree ["--nope" "1"] {:inherited {:foo {:coerce :long}}})))))) + (cli/dispatch tree ["--nope" "1"] global)))))) (deftest dispatch-tree-input-test ;; dispatch accepts a tree (the table->tree shape) directly @@ -1161,19 +1160,19 @@ (is (= (str "Usage: p sub [options]\n\n" "Options:\n --x local x") (cli/format-command-help {:table t :cmds ["sub"] :prog "p"}))))) - (testing ":inherited lists options declared elsewhere, with no ancestor to carry them" + (testing "the dispatch-level :spec is listed, having no ancestor to carry it" (is (= (str "Usage: p [options]\n\n" "Options:\n --bar own\n\n" - "Inherited options:\n --foo from elsewhere") + "Inherited options:\n --foo global foo") (cli/format-command-help {:table {:spec {:bar {:desc "own"}}} :prog "p" - :inherited {:foo {:desc "from elsewhere"}}}))) + :spec {:foo {:desc "global foo"}}}))) (testing "the command's own spec wins over it" (is (= (str "Usage: p [options]\n\n" "Options:\n --bar own") (cli/format-command-help {:table {:spec {:bar {:desc "own"}}} :prog "p" - :inherited {:bar {:desc "from elsewhere"}}}))))) + :spec {:bar {:desc "global bar"}}}))))) (testing ":args->opts renders labeled positionals in the usage line" (let [t [{:cmds ["copy"] :fn identity :doc "Copy" :args->opts [:src :dest] :spec {:force {:desc "Force"}}}]] From 7fd137063f975ecd941f98e118995789bb10cc5e Mon Sep 17 00:00:00 2001 From: Michiel Borkent Date: Tue, 4 Aug 2026 12:23:07 +0200 Subject: [PATCH 4/4] Add changelog entries for the dispatch-level spec in help --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b6abe7..77168ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ For breaking changes, check [here](#breaking-changes). ## Unreleased +- Help: show the dispatch-level `:spec` options under `Inherited options:`. The parser always accepted these options, but help did not show them +- Help: `format-command-help` accepts `:spec`, the dispatch-level spec, so a standalone call shows the same options as `dispatch` - Completion: do not offer `:positional` keys as options, they are rejected when typed as a flag ## 0.12.85