sxcli.devSimple Extensible CLI
You are viewing an archived snapshot (v0.1.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"
)

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

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

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