JustBash.CLI behaviour (JustBash v0.4.0)
View SourceDeclarative, namespaced subcommand tools for JustBash.
JustBash.CLI turns a tree of subcommands — like acme pr review --report 1234 — into
a single value you register in the :commands map. It handles routing, typed
argument parsing, and auto-generated help and errors, so host applications stop
hand-rolling case-statement routers and hand-maintained --help text.
A CLI is plain data: a %JustBash.CLI{} holding a tree of JustBash.CLI.Command nodes.
Build it with new/2 and command/2, then register the struct directly:
alias JustBash.CLI
cli =
CLI.new("acme", doc: "Acme operations toolkit", commands: [
CLI.command("pr", doc: "Pull request management", commands: [
CLI.command("review",
doc: "Review a pull request",
flags: [
report: [type: :integer, required: true, doc: "ID of the report to review"],
format: [type: :string, default: "text", values: ~w(text json), doc: "Output format"],
verbose: [type: :boolean, short: "-v"]
],
run: &Acme.PR.review/1)
])
])
bash = JustBash.new(commands: %{"acme" => cli})Each leaf's :run is a one-argument handler that receives a
JustBash.CLI.Invocation and returns {result, bash} — the same contract as a
custom JustBash.Commands.Command:
def review(%JustBash.CLI.Invocation{flags: flags, bash: bash}) do
report = MyApp.PullRequests.fetch!(flags.report, bash.context.user)
{JustBash.Commands.Command.ok(render(report, flags.format)), bash}
endFlags
Flag specs use the exact shape of JustBash.Commands.ArgParser: a keyword list of
name: [type: ..., ...]. Supported keys: :type (:boolean, :string, :integer,
:float, :accumulator), :short, :long (defaults to --name), :aliases,
:default, :required, :values (enum), :transform, and :doc (used in help output).
Flag specs are validated at build time (command/2 raises ArgumentError):
- an unrecognized spec key is rejected — a typo'd or imagined key would otherwise be silently ignored and indistinguishable from a working one.
--help/-hare reserved — see "Reserved flags" below.:requiredand:defaultare mutually exclusive (a required flag errors when omitted, so the default could never apply).- a
:defaultmust be a member of:valueswhen both are given (the enum check only runs on flags the user actually provides, so an out-of-range default would slip past it). - an alias must start with
--, and no two flags may share a long form, an alias, or a short form — the parser indexes them into one map, so a collision would silently bind the wrong flag. - a long form or alias may not contain
=— the parser splits--flag=valueon the first=before matching, so such a spelling could never be reached.
Flag aliases
:aliases gives one flag extra long spellings, for renaming a flag without breaking
callers: target_on: [type: :string, aliases: ["--target-date"]] accepts both
--target-on and --target-date. :long stays canonical and is the only form shown in
usage lines, help, and describe/1 — aliases are accepted, not advertised.
:values is compared against the coerced value, so list members must match the flag's
:type — e.g. type: :integer, values: [1, 2] (integers, not ~w(1 2)). The raw
:values list is also what describe/1 and the help text surface to agents.
Flag names are atoms, so a name that is an Elixir reserved word (e.g. end, fn, do)
can't be written bare in the keyword list. Give it an explicit :long instead:
end_date: [type: :string, long: "--end"].
Beyond required-ness, types, and :values, two hooks cover custom validation, both
producing the same exit-2 + usage-line failure as a flag error:
- a flag's
:transformmay return{:error, message}for single-field checks (a numeric range, a parseable date); - a command-level
:validatecallback runs after parsing for cross-field rules (start <= end). Seecommand/2.
Passthrough flags
A leaf that wraps a backend whose flags aren't known at definition time can set
allow_unknown_flags: true. Undeclared flags are then collected into
Invocation.extra_flags as a raw token list (ready to forward verbatim) instead of
erroring, while declared flags and positionals are parsed as usual. Put declared
positionals before passthrough flags, and prefer --flag=value form for unambiguous
forwarding. See command/2.
Authorization
To make a subtree present only for some callers — genuinely absent, not just hidden — there are two approaches:
- Build the tree from context (first-class). A
%JustBash.CLI{}is plain data, so build it per session and conditionally append gated groups based onbash.contextbefore registering it. Routing, help, anddescribe/1all reflect exactly the tree you built. This is the most flexible path and the right one for fully dynamic trees. - A
:visible?predicate (declarative sugar). Attachvisible?: fn bash -> ... endto a node;run/4prunes nodes the predicate rejects before routing, so they're unroutable (reported as unknown commands) and omitted from help. Pass the samebashtodescribe/2/render_docs/2to get the catalog as that caller sees it.
Reserved flags
--help and -h are reserved: the router intercepts them before a leaf ever parses, so a
leaf can request help in one turn. A flag spec may not claim either form (including a flag
named :help, whose derived long is --help) — command/2 raises if it does. Because
interception happens first, --help/-h anywhere before a -- terminator wins even when
it would otherwise be a flag's value (e.g. acme pr review --format -h shows help).
-- handling
A leading -- before a subcommand is consumed and routing continues (acme -- pr review
reaches pr review), so wrappers that prepend -- still route. This is intentionally not
POSIX -- semantics — -- only acts as an options/help terminator once routing reaches a
leaf and hands the remaining tokens to the parser.
Positional arguments
Positionals are a flat list, so command/2 rejects ambiguous shapes at build time: a
required positional may not follow an optional one, and a variadic must be last. A lone
- (the stdin convention) is treated as a flag by the router and is not supported as a
positional; pass it after -- if a leaf needs it as a literal value.
Trust model
CLI handlers are ordinary host Elixir code with the same trust model and crash isolation as any custom command — they are not sandboxed. A crashing handler is caught and turned into an error result, but it runs with full access to the host.
Summary
Callbacks
Returns the CLI definition. Implemented for you when you use JustBash.CLI; you supply
spec/0.
Functions
Make a module be a CLI command, so it can live alongside other
JustBash.Commands.Command modules and be registered by module name
Returns true if value is a CLI — a %JustBash.CLI{} struct or a module that
uses JustBash.CLI.
Returns true if module uses JustBash.CLI.
Build a single command node.
A human description of a registered custom-command value, for type and command -V.
Return a plain-data description of the CLI's command tree.
Invoke a leaf at an explicit path, bypassing routing and visibility.
Build a CLI tool rooted at name.
Render the CLI as a standalone document.
Run a CLI against raw arguments.
Types
A CLI value: either a built struct or a module that uses JustBash.CLI.
@type t() :: %JustBash.CLI{ aliases: [String.t()], commands: [JustBash.CLI.Command.t()], doc: String.t() | nil, name: String.t(), on_missing_subcommand: :error | :help }
Callbacks
@callback spec() :: t()
Returns the CLI definition. Implemented for you when you use JustBash.CLI; you supply
spec/0.
Functions
Make a module be a CLI command, so it can live alongside other
JustBash.Commands.Command modules and be registered by module name:
defmodule Acme.CLI do
use JustBash.CLI
@impl true
def spec do
JustBash.CLI.new("acme", doc: "Acme toolkit", commands: [...])
end
end
bash = JustBash.new(commands: %{"acme" => Acme.CLI})This injects a JustBash.Commands.Command-compatible names/0 and execute/3 that
delegate to your spec/0. It is conventional use-wiring (like use GenServer), not a
DSL — the definition is still the plain %JustBash.CLI{} you return from spec/0.
Registering the struct directly (%{"acme" => Acme.CLI.spec()}) works too and skips the
module entirely; both run on the same engine.
Returns true if value is a CLI — a %JustBash.CLI{} struct or a module that
uses JustBash.CLI.
Returns true if module uses JustBash.CLI.
@spec command( String.t(), keyword() ) :: JustBash.CLI.Command.t()
Build a single command node.
A node is a group (routes to children) when given :commands, or a leaf (does
work) when given :run. Exactly one of the two must be provided.
Options
:doc— one-line description shown in help output:commands— nested child nodes (makes this a group):run— a one-arg handler(JustBash.CLI.Invocation.t() -> {map(), JustBash.t()})(makes this a leaf):flags— anArgParserflag spec (leaves only):args— positional argument specs (leaves only); seeJustBash.CLI.Command.arg_spec/0:examples— a list of worked examples (leaves only); each is a string or a map%{cmd: String.t(), doc: String.t() | nil}, surfaced in help,describe/1, and docs:validate— a(JustBash.CLI.Invocation.t() -> :ok | {:error, String.t()})callback (leaves only) run after parsing and before:run; an{:error, msg}produces the same exit-2 + usage line as a flag error, giving cross-field validation a home:allow_unknown_flags— whentrue(leaves only), undeclared flags are collected intoInvocation.extra_flags(a raw token list) instead of erroring. Declared positionals should precede passthrough flags, and--flag=valueis forwarded as one token:visible?— a(JustBash.t() -> boolean())predicate; when it returnsfalsethe node is absent for that caller — unroutable (reported as an unknown command) and omitted from help anddescribe/2:on_missing_subcommand—:error(default) or:help(groups only);:helpprints the command listing at exit 0 instead of a usage error when the group is invoked bare
Raises ArgumentError on invalid shape (e.g. both or neither of :commands/:run), and
on an unrecognized or repeated option — including inside an :args entry. A key this
builder does not read would be dropped unread and behave exactly like one that works;
visible: for :visible? would silently discard an authorization predicate, and
requird: on a positional would silently make a required argument optional.
A human description of a registered custom-command value, for type and command -V.
CLI values report their subcommand count ("acme is a CLI tool (12 commands)"); plain
command modules fall back to bash's terse "name is name".
@spec describe(spec(), JustBash.t() | nil) :: map()
Return a plain-data description of the CLI's command tree.
Useful for generating agent-facing documentation or building tab completion. Every
leaf is listed with its full invocation path, resolved flag/argument specs, worked
examples, and whether it accepts passthrough flags (allow_unknown_flags):
JustBash.CLI.describe(cli)
#=> %{
# name: "acme",
# doc: "Acme operations toolkit",
# aliases: [],
# commands: [
# %{path: ["pr", "review"], doc: "Review a pull request",
# flags: [%{name: :report, type: :integer, required: true, ...}], args: [],
# examples: [%{cmd: "acme pr review --report 42", doc: nil}],
# allow_unknown_flags: false},
# ...
# ]
# }Pass a bash as the second argument to get the catalog as a specific caller sees it:
nodes whose :visible? predicate returns false for that bash are omitted, mirroring
what routing and --help expose. With no bash (the default), every node is described.
@spec invoke(spec(), [String.t()], [String.t()], JustBash.t(), String.t()) :: {map(), JustBash.t()}
Invoke a leaf at an explicit path, bypassing routing and visibility.
Intended for handler-level unit tests: it builds the %JustBash.CLI.Invocation{} exactly
as run/4 does — merging flag defaults, collecting extra_flags, and running :validate
— then calls the handler and returns {result, bash}. Prefer this (or run/4) over
hand-building an %Invocation{}, which skips default-merging.
{result, _bash} = JustBash.CLI.invoke(spec, ["pr", "review"], ["--report", "7"], bash)Because it skips visibility pruning, invoke/5 is not an authorization boundary: it will
dispatch a leaf whose :visible? predicate would reject this bash. Use run/4 for any
caller-gated dispatch; reach for invoke/5 only in tests or trusted internal call sites.
Raises ArgumentError if path does not resolve to a leaf.
Build a CLI tool rooted at name.
Options
:doc— one-line description of the tool:commands— a list of top-levelJustBash.CLI.Commandnodes (built withcommand/2):aliases— additional names the tool can be registered under:on_missing_subcommand—:error(default) or:help; what the root does when invoked with no subcommand (seecommand/2)
Raises ArgumentError if names are invalid, top-level command names collide, or an
option is unrecognized or repeated — an option this function does not read would be
dropped unread, indistinguishable from one that works.
Render the CLI as a standalone document.
Pass format: :text (default) for a plain-text manual, or format: :markdown for a
markdown document suitable for an agent's system prompt. Pass bash: bash to render only
the commands that caller can see (see describe/2).
@spec run(t(), JustBash.t(), [String.t()], String.t()) :: {map(), JustBash.t()}
Run a CLI against raw arguments.
Routes args through the command tree, parses the matched leaf's flags and
positionals, and invokes its handler. Returns {result, bash} — the same shape as
JustBash.Commands.Command.execute/3.
Usage problems (unknown subcommand, bad flag, missing argument) return an error result
with exit code 2 and a usage hint, rather than raising. A command-level :validate
failure uses the same exit-2 + usage-line shape.
Nodes whose :visible? predicate rejects this bash are pruned before routing, so they
are reported as unknown commands and never appear in help (see the moduledoc's
"Authorization" section).
Result carries :__subcommand__
The returned result map includes a :__subcommand__ key holding the resolved path,
for command telemetry. The shell executor reads it into span metadata and strips it
before the result reaches the shell, but a host calling run/4 (or invoke/5) directly
will see it. It's safe to ignore — read :exit_code/:stdout/:stderr as usual.