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

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():

go
go
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()
}
go
go
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:

go
go
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:

sh
sh
$ 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.

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:

sh
sh
$ 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:

sh
sh
$ 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.