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" ) type Config struct { Trim bool `json:"trim" arg:"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() { u := &Upper{} fw.Register("upper", u, fw.WithConfig(&u.cfg)) }
package count import ( "bufio" "fmt" "os" fw "sxcli.dev/fw" ) type Config struct { Words bool `json:"words" arg:"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() { c := &Count{} fw.Register("count", c, fw.WithConfig(&c.cfg)) }
main.go just links them in:
package main import ( fw "sxcli.dev/fw" _ "sxcli.dev/fw/configfmt/yaml" _ "sxcli.dev/fw/sink/console" _ "example.com/tbx/count" _ "example.com/tbx/upper" ) func main() { fw.Main() }
As in the minimal CLI demo, the configfmt/yaml import
is optional: JSON configs are the default and always enabled — the core
handles them natively with nothing to import. 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 id anchors its own configuration surface — env prefixes
(UPPER_TRIM, COUNT_WORDS) and config file locations
(upper-config.yaml next to the real binary, /etc/upper/config.yaml,
the XDG user location). 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. The design spec covers the full model, and
the API reference documents every public symbol.