Busybox-style
The other end of the spectrum from the minimal CLI demo: one binary carrying several applets, dispatched busybox-style by symlink name or first argument. Each applet keeps its own configuration namespace; the binary stays a single artifact to build, ship and install.
Two applets#
A tiny text toolbox, tbx. Each applet lives in its own package and
registers itself from init():
package upper import ( "bufio" "fmt" "os" "strings" fw "sxcli.dev/fw" ) const ID = "example.com/tbx/upper" type Config struct { Version uint32 `json:"version"` Trim bool `json:"trim" conf:"trim,t" usage:"trim whitespace first"` } type Upper struct{ cfg Config } func (u *Upper) Configured() error { return nil } func (u *Upper) Run() int { in := bufio.NewScanner(os.Stdin) for in.Scan() { line := in.Text() if u.cfg.Trim { line = strings.TrimSpace(line) } fmt.Println(strings.ToUpper(line)) } return 0 } func init() { fw.NewRegistration(ID, func() *Upper { return &Upper{cfg: Config{Version: 1}} }, func(u *Upper) *Config { return &u.cfg }, ).Alias("upper").Register() }
package count import ( "bufio" "fmt" "os" fw "sxcli.dev/fw" ) const ID = "example.com/tbx/count" type Config struct { Version uint32 `json:"version"` Words bool `json:"words" conf:"words,w" usage:"count words instead of lines"` } type Count struct{ cfg Config } func (c *Count) Configured() error { return nil } func (c *Count) Run() int { in := bufio.NewScanner(os.Stdin) if c.cfg.Words { in.Split(bufio.ScanWords) } n := 0 for in.Scan() { n++ } fmt.Println(n) return 0 } func init() { fw.NewRegistration(ID, func() *Count { return &Count{cfg: Config{Version: 1}} }, func(c *Count) *Config { return &c.cfg }, ).Alias("count").Register() }
main.go names what the toolbox is made of:
package main import ( "sxcli.dev/fw" "sxcli.dev/fw/configfmt/yaml" "example.com/tbx/count" "example.com/tbx/upper" ) func main() { fw.Builder(). Accept(upper.ID, count.ID, yaml.ID). Main() }
Every import there is justified by a name the composition uses. Drop
count.ID from the Accept list and the count import stops compiling —
the binary's contents and its import block cannot drift apart.
As in the minimal CLI demo, configfmt/yaml is
optional: JSON configs are the default and always enabled — the core
handles them natively with nothing to accept. The YAML provider only adds
.yaml/.yml support alongside.
Dispatch#
With more than one applet registered, the framework needs to know which one to run — by first argument, or busybox-style by the name the binary was invoked under:
$ tbx upper --trim < notes.txt # first argument selects the applet $ tbx count --words < notes.txt $ tbx frobnicate # unknown applet: usage + applet list, exit non-zero
--help is per-applet: tbx upper --help shows upper's schema, not a
catalog of everything in the binary.
Symlinks, busybox-style#
Install the binary once and lay down one symlink per applet — the classic
busybox pattern. When the process starts, basename(argv[0]) names the
applet, so each link behaves like a dedicated tool:
$ install -m 0755 tbx /usr/local/bin/tbx $ ln -s tbx /usr/local/bin/upper $ ln -s tbx /usr/local/bin/count
Invoked through the links, no selector argument is needed — the remaining arguments belong to the applet, exactly as if it were its own binary:
$ echo "some text" | upper --trim SOME TEXT $ upper --help # upper's schema, under the name upper $ count --words < notes.txt 42
One artifact to build and ship, as many command names as the toolbox has
applets — and tbx upper … keeps working alongside the links.
Per-applet namespaces#
Each applet's alias anchors its own configuration surface — env
prefixes (UPPER__TRIM, COUNT__WORDS), config file sections, and
config file locations (upper-config.yaml next to the real binary,
/etc/upper/config.yaml, the XDG user location). The path-shaped id
stays in the code; operators only ever see the alias. A symlink never relocates the binary-companion
config: "next to the binary" means next to the real binary, symlinks
resolved, so a link in an attacker-writable directory cannot choose the
configuration.
The contract change to know about#
Registering a second applet re-enables selector logic — in a multi-applet binary a leading bare token is always an applet selector, never data. Going from one applet to two changes the binary's command-line contract; that is a deliberate, documented trade — Dispatch & applets explains the reasoning and the full rules.
Going further#
From here the rest of the machinery is incremental: shared services with
inject struct tags, log sinks, config format providers, Windows SCM
applets. Adding a sink is one more Accept; a name collision between two
upstream applets is settled with Builder.Alias without touching either
package — see Composition & the Builder. The design spec covers the full model, and
the API reference documents every public symbol.