JustBash.CLI behaviour (JustBash v0.4.0)

View Source

Declarative, 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}
end

Flags

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/-h are reserved — see "Reserved flags" below.
  • :required and :default are mutually exclusive (a required flag errors when omitted, so the default could never apply).
  • a :default must be a member of :values when 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=value on 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 :transform may return {:error, message} for single-field checks (a numeric range, a parseable date);
  • a command-level :validate callback runs after parsing for cross-field rules (start <= end). See command/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 on bash.context before registering it. Routing, help, and describe/1 all 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). Attach visible?: fn bash -> ... end to a node; run/4 prunes nodes the predicate rejects before routing, so they're unroutable (reported as unknown commands) and omitted from help. Pass the same bash to describe/2/render_docs/2 to 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

Types

A CLI value: either a built struct or a module that uses JustBash.CLI.

t()

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

spec()

@type spec() :: t() | module()

A CLI value: either a built struct or a module that uses JustBash.CLI.

t()

@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

spec()

@callback spec() :: t()

Returns the CLI definition. Implemented for you when you use JustBash.CLI; you supply spec/0.

Functions

__using__(opts)

(macro)

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.

cli?(module)

@spec cli?(term()) :: boolean()

Returns true if value is a CLI — a %JustBash.CLI{} struct or a module that uses JustBash.CLI.

cli_module?(module)

@spec cli_module?(module()) :: boolean()

Returns true if module uses JustBash.CLI.

command(name, opts \\ [])

@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 — an ArgParser flag spec (leaves only)
  • :args — positional argument specs (leaves only); see JustBash.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 — when true (leaves only), undeclared flags are collected into Invocation.extra_flags (a raw token list) instead of erroring. Declared positionals should precede passthrough flags, and --flag=value is forwarded as one token
  • :visible? — a (JustBash.t() -> boolean()) predicate; when it returns false the node is absent for that caller — unroutable (reported as an unknown command) and omitted from help and describe/2
  • :on_missing_subcommand — :error (default) or :help (groups only); :help prints 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.

custom_command_description(value, name)

@spec custom_command_description(term(), String.t()) :: String.t()

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".

describe(cli_or_module, bash \\ nil)

@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.

invoke(cli_or_module, path, args, bash, stdin \\ "")

@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.

new(name, opts \\ [])

@spec new(
  String.t(),
  keyword()
) :: t()

Build a CLI tool rooted at name.

Options

  • :doc — one-line description of the tool
  • :commands — a list of top-level JustBash.CLI.Command nodes (built with command/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 (see command/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_docs(cli_or_module, opts \\ [])

@spec render_docs(
  spec(),
  keyword()
) :: String.t()

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).

run(cli, bash, args, stdin)

@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.