sxcli.devSimple Extensible CLI
You are viewing an archived snapshot (v0.3.0). Switch to the current version →

Dispatch & applets

Dispatch is how the binary decides which applet runs. fw.Main() takes no parameters by design — the argument vector is platform-sourced: os.Args on POSIX, and in Windows service mode the vector the Service Control Manager hands to Execute.

One applet is active#

A binary can carry many applets, but only one is ever active. Dispatch chooses it, and only that applet enters the dependency graph — the others are never resolved, never configured, never started. Their arguments are not parsed and their config sections are not read.

What the number of applets decides, then, is not whether they can coexist but whether the binary needs a selector word to tell them apart. That is the whole of the two modes below.

Applet visibility#

Most applets are plain public commands. Two registration options change how the framework treats an applet — policy, not capability, which is why they are options rather than interfaces:

RegistrationListed in usageargv[0] dispatchExplicit binary <alias>Counts for single-applet mode
plainyesyesyesyes
.Hidden()nonoyesyes
.System()nonoyesno
go
go
fw.NewRegistration(DumpID, newDump, dumpCfg).
    Alias("debugdump").Hidden().Register()   // a maintenance command

fw.NewRegistration(CompleteID, newComplete, completeCfg).
    Alias("completionbash").System().Register()  // machinery, never typed
  • Hidden is a command you can still type but that stays out of listings and symlink dispatch — debug and maintenance entry points.
  • System declares machinery of the binary that a human is never meant to type; a shell-completion query endpoint is the canonical case. It implies Hidden, and crucially it is ignored by single-applet counting, so a module registering one can never flip an existing binary's dispatch mode.
  • Either option on a service that is not an applet is a registration error.

In every other respect they are ordinary applets: id and alias rules, the ALIAS__ env prefix, config files, closure resolution and the lifecycle are unchanged.

Dispatch speaks aliases, never ids. Both the selector word and the argv[0] basename are looked up among aliases, so the path-shaped id your code uses never appears on a command line. Any of a service's aliases selects it; listings show the primary one.

Single-applet mode#

With exactly one non-System applet in the composition, there is nothing to decide — it is always dispatched, and selector logic is off:

  • argv[0] is ignored; binary name, symlinks, install location are all irrelevant.
  • The entire argument vector after the binary name belongs to the applet as ordinary flags and positionals. mytool myapplet --args does not treat myapplet as a selector even if it happens to match the applet's alias — it is a leading bare token under normal parsing, so there is no "data or selector?" ambiguity to reason about.

One carve-out: a first bare token equal to a registered System applet's alias selects that applet. So with System applets present, not every start of the binary runs the main applet — and a genuine positional colliding with a System alias needs the standard leading -- escape. That is the price of letting completion scripts call binary <systemalias> … without disturbing your command line.

Only dispatch is simplified. The applet's primary alias still anchors everything else — the MYAPPLET__ env prefix, the config file names and section, closure resolution, and the lifecycle all proceed as usual.

Selector rules (multi-applet)#

With two or more non-System applets in the composition, two rules decide, in order:

  1. If the first argument exists and does not start with -, it is always an applet selector. Look it up, dispatch with the remaining arguments. An unknown name is a dispatch failure — even if basename(argv[0]) would itself name a valid applet. No fallback. Hidden and System applets are selectable here like any other: explicit selection always works.
  2. Otherwise, basename(argv[0]) must name a registered non-Hidden applet — the busybox symlink style. On Windows the .exe suffix is stripped before matching.
sh
sh
$ tbx upper --trim < notes.txt   # rule 1: first bare argument selects
$ upper --trim < notes.txt       # rule 2: argv[0] selects (a symlink to tbx)
$ tbx frobnicate                 # unknown selector: usage + applet list,
                                 # exit non-zero — no argv[0] fallback

The consequence, worth stating bluntly: in a multi-applet binary a leading bare token is never applet data. Rule 1 runs first even when the binary was invoked through a symlink — so upper notes.txt does not hand notes.txt to the upper applet; it fails dispatch looking for an applet named notes.txt. Scripts targeting multi-applet binaries keep positionals behind a flag or after --, and nobody gets surprised.

The contract change to plan for#

Registering a second non-System applet re-enables selector logic — and that changes the binary's command-line contract: yesterday mytool report.txt was a positional, today it is an unknown-applet dispatch failure. That hard flip is deliberate. Mixing and matching applets is a core idea of the framework — but an applet written assuming it owns the whole command line must not silently survive gaining a sibling. By failing loudly instead of guessing, the framework makes sure the composition is a conscious decision: code destined for multi-applet binaries is written — and its invocations are written — expecting the selector contract from the start. The busybox demo shows that side in practice.

This is exactly why System applets are excluded from the count: accepting a completion module must never rewrite your binary's command line as a side effect. Adding a plain applet is a decision; adding machinery is not supposed to be one.

Dispatch failures, `--help`, and listing applets#

Every dispatch failure — unknown selector, unmatched argv[0], or a binary with zero registered applets — prints usage to stderr, including one line per public applet alias (Hidden and System ones omitted), and exits 2. In single-applet mode the applet list is dropped from that output.

--help is deliberately different: it renders only the dispatched applet's argument schema — core + its closure, grouped by service, with the positional shape — and never an applet catalog. A core argument for enumerating applets (something like --applets) is planned but not yet built; today the list only appears in dispatch-failure usage output. Programmatically, though, the enumeration exists: the core Introspector's Applets() (public applets only) and SingleApplet() (dispatch-mode truth) — see Services & injection.

Positionals#

Positionals are declared, not collected. The applet's config struct binds them with pos: tags — pos:"0" for an indexed scalar, pos:"rest" for the tail — so they are typed, validated and rendered in --help like every other field. fw.Positionals() is gone; see Your config struct for the tag rules.

Parsing still works the way it did:

  • Every bare token after the last flag argument is collected as a positional.
  • A bare token followed by another flag is a strict-parse error — positionals must come last.
  • A literal -- ends flag parsing; everything after it is positional, dashes and all.

Only applets may declare positionals. A plain service carrying a pos: tag is a registration error. Flags and env vars are namespaced per service, but there is one command line and it belongs to the applet being dispatched — two services claiming argument 0 could only be resolved by composition order, which is exactly what this release set out to eliminate.

That ownership is per dispatched applet, not per binary: in a multi-applet tool each applet has its own positional contract, and the others' declarations lie dormant.

Related: the busybox demo for the practice, the lifecycle page for what happens after dispatch, and the design spec for the rules verbatim.