sxcli.devSimple Extensible CLI

Minimal CLI

Not every tool needs services, injection or applet dispatch. This demo is the framework at its smallest: one applet, one config struct, one file — used purely for best-in-class argument, environment and config-file handling. If that is all you need, that is all you pay for.

The whole tool#

A static file server, srv, in a single main.go:

go
go
package main

import (
    "log/slog"
    "net/http"

    "sxcli.dev/fw"
)

// ID is this service's public handle — what compositions and inject
// tags name. A constant, never a magic string.
const ID = "example.com/srv"

type Config struct {
    Version uint32 `json:"version"`
    Addr    string `json:"addr" conf:"addr,a" usage:"listen address"`
    Dir     string `json:"dir"  conf:"dir,d"  usage:"directory to serve"`
}

type Srv struct{ cfg Config }

func (s *Srv) Configured() error { return nil }

func (s *Srv) Run() int {
    slog.Info("serving", "dir", s.cfg.Dir, "addr", s.cfg.Addr)
    if err := http.ListenAndServe(s.cfg.Addr, http.FileServer(http.Dir(s.cfg.Dir))); err != nil {
        slog.Error("server failed", "err", err)
        return 1
    }
    return 0
}

func main() {
    fw.Solo(fw.NewRegistration(ID,
        // the factory sets the defaults — cheap by contract, no I/O
        func() *Srv { return &Srv{cfg: Config{Version: 1, Addr: ":8080", Dir: "."}} },
        func(s *Srv) *Config { return &s.cfg },
    ).Alias("srv"))
}

No services beyond the applet itself, no inject tags, no lifecycle code — Run blocks until the process is done, and that is fine.

fw.Solo is the single-applet front door: it registers the one service and composes everything catalogued, so there is no builder to write.

What the tags bought you#

Every field with a conf tag is now settable four ways, merged with fixed precedence — defaults < config files < environment < arguments:

sh
sh
$ srv --addr :9090 -d /var/www     # arguments
$ SRV__ADDR=:9090 srv              # environment — alias, "__", conf name
$ echo '{"srv": {"version": 1, "addr": ":9090"}}' > srv-config.json
$ srv --help                       # full schema with current effective values

The environment names (SRV__ADDR, SRV__DIR) come from the applet's alias and the conf names; the config file keys nest in a section named after the same alias, alongside the schema version. Standard config locations are searched automatically: next to the real binary, /etc/srv/, and the XDG user config directory.

JSON is the native config format — always enabled, nothing to accept. To read .yaml and .yml as well, accept the bundled provider, which means naming a composition instead of using Solo:

go
go
import (
    "sxcli.dev/fw"
    "sxcli.dev/fw/configfmt/yaml"
)

func main() {
    fw.NewRegistration(ID, // same factory and accessor as above
        func() *Srv { return &Srv{cfg: Config{Version: 1, Addr: ":8080", Dir: "."}} },
        func(s *Srv) *Config { return &s.cfg },
    ).Alias("srv").Register()

    fw.Builder().Accept(ID, yaml.ID).Main()
}

Drop that and JSON keeps working — format providers are purely additive.

Why the version field#

Version uint32 is required on every config struct, from the first release. It costs one line now and means that when the schema changes, config files already deployed keep loading through a declared migration chain instead of breaking — see Config migrations.

Single-applet mode#

With exactly one applet, dispatch is off: the entire argument vector after the binary name belongs to srv — no subcommand is consumed, argv[0] is ignored, and there is no selector ambiguity to think about. Binary name, symlinks, install location: all irrelevant.

Positionals, if you want them, are declared on the same struct with pos: tags — and only an applet may declare them.

Bootstrap a config file#

--write-config writes the merged effective configuration — a quick way to generate a starting config or normalize an existing one:

sh
sh
$ srv --addr :9090 --config srv.json --write-config
$ cat srv.json
{
    "srv": {
        "version": 1,
        "addr": ":9090",
        "dir": "."
    }
}

Takeaway#

A flag package with config-file and environment support, --help, and strict validation — for the cost of one struct and two tags per field. The advanced machinery (metadata, services, injection, sinks, applet dispatch) stays out of the way until the day you want it — the advanced CLI demo is the first step up, the busybox-style demo the next.

If even the service model is more than you want, the same configuration engine stands alone as sxcli.dev/conf.