sxcli.devSimple Extensible CLI

Overview

sxcli.dev/conf turns one annotated Go struct into a complete operator surface: every field is simultaneously a command-line argument, an environment variable and a config-file key — merged with clear precedence, validated strictly, and handed to your code filled in. No command objects, no Get("key"), no global mutable state.

It is the configuration engine of sxcli.dev/fw, extracted so you can use it without the framework. If you want argument, environment and config-file handling and nothing else — no services, no lifecycle, no dispatch — this is the whole of it.

The whole program#

go
go
package main

import (
    "fmt"
    "log"
    "time"

    "sxcli.dev/conf"
)

type Config struct {
    Version uint32        `json:"version"` // the schema's version, for migrations
    Listen  string        `json:"listen" conf:"listen,l" usage:"address to serve on"`
    Timeout time.Duration `json:"timeout" conf:"timeout" usage:"request timeout"`
    Token   string        `json:"token" env:"MYTOOL_TOKEN"` // env+file only
}

func main() {
    cfg := Config{Version: 1, Listen: ":8080", Timeout: 30 * time.Second}
    l, served := conf.New("mytool", &cfg)
    if served {
        return // the run was already answered — see below
    }
    if _, err := l.Result(); err != nil {
        log.Fatal(err)
    }
    fmt.Println("serving on", cfg.Listen)
}

All of these set the same field:

console
console
$ mytool --listen :9090
$ MYTOOL__LISTEN=:9090 mytool
$ echo '{"version": 1, "listen": ":9090"}' > /etc/mytool/config.json && mytool

Note served. The front door is deliberately two-phase: when the run was already answered, served is true and your program should return without doing its work. Discarding that value means a --help run falls through and executes anyway — exactly the mistake sxcli-vet exists to catch.

A run is served by --help (always, even if the merge failed), --upgrade-config when it succeeds, --validate-config when the config is clean, and --write-config when the file was written. The mirror image matters just as much: a violated --validate-config or --write-config is not served, because the verdict is a failure you must read from Result().

Result() returns the trailing positional arguments and an error. Two misuses are loud rather than silent: calling it before Load, and calling it on a served run. And when it returns an error your config struct still holds its untouched defaults — a failed load never leaves you half-configured.

Flat files, by default#

The struct binds at the root of the config file, the way the rest of the world writes them:

json
json
{"version": 1, "listen": ":9090", "timeout": "5s"}

That is the one visible difference from fw, which nests each service's keys in a section named after its alias — it has many services to keep apart, and a standalone tool has one struct.

One struct, four sources#

code
text
struct defaults  <  config files  <  environment  <  arguments

Files are discovered in three tiers, merged in this order — later overrides earlier:

TierPath
companion<binary-dir>/<name>-config.<ext>, next to the real binary (symlinks resolved)
system/etc/<name>/config.<ext>, or %ProgramData%\<name>\ on Windows
useros.UserConfigDir() + <name>/config.<ext> — XDG on Linux, Application Support on macOS, %AppData% on Windows

An explicit --config path replaces the search entirely, and that file must exist. JSON is native; a format provider adds anything else, and the YAML one ships ready-made:

go
go
import "sxcli.dev/conf/yaml"

l, served := conf.NewLoader("mytool", &cfg).Provider(&yaml.YAML{}).Load()

Durations are unit-checked (5s, never a bare number), slices repeat flags, positionals are declared with pos: tags, and unknown arguments and unknown file keys are startup errors. Silent misconfiguration is the worst bug class, so nothing here is silent.

The tags, types, positional rules and metadata are documented in full on the framework's config struct page — the same engine drives both, so everything there applies here, minus the section nesting.

Schema migrations#

Schemas change; deployed config files do not. Declare the history as typed conversions and old files keep loading:

go
go
l, served := conf.NewLoader("mytool", &cfg).
    Migrate(
        conf.Step(1, func(old ConfigV1) ConfigV2 { /* … */ }),
        conf.Step(2, func(old ConfigV2) Config { /* … */ }),
    ).Load()

--upgrade-config modernizes a file in place as a pure transform — it never loads your configuration, so no environment variable or argument can leak into the result, and sections it does not own pass through verbatim. It requires an explicit --config target: there is no guessing which discovered file you meant to rewrite.

--validate-config runs every check and reports without running your program. Note the two outcomes differ: a clean config is served, while a violated one is not — that failure arrives through Result() like any other. Either way your struct keeps its defaults.

The migration rules themselves are the same as the framework's — see Config migrations.

The author owns the surface#

go
go
conf.NewLoader("mytool", &cfg).
    Suppress(conf.FeatureUserConfig, conf.FeatureCompanionConfig).
    Load()

Suppressed features vanish: arguments become unknown, env vars are never read, search tiers are never probed. Every knob is a typed constant, in three groups:

  • argumentsFeatureConfig, FeatureWriteConfig, FeatureHelp, FeatureValidateConfig, FeatureUpgradeConfig (which takes --from-version with it — that flag is inert alone).
  • search tiersFeatureCompanionConfig, FeatureSystemConfig, FeatureUserConfig. These apply to the standard search; a caller-supplied Sources is yours to define.
  • the environmentFeatureEnvironment kills the whole source, and note that means every binding: explicit env:"TOOL_TOKEN" tags die along with the derived names.

The packages#

  • conf — the front door: New, NewLoader, and the chain that configures it — Suppress, Migrate, MaxSize, Provider, Sources, Output, then the terminal Load and Result.
  • engine — the machinery: schema extraction, parsers, file discovery, source merging. This is what a framework builds on; sxcli.dev/fw is its first consumer.
  • fail — the shared violation collector: record every problem, fail once listing all of them.
  • yaml — the ready-made YAML format provider.
  • sxcli.dev/vet — static analysis for the tag grammar, the version mandate, migration chains and the served return, at compile time. A tags-and-reflection model is only defensible with tooling that closes its gaps.
  • sxcli.dev/fw — the full framework, when you want services, dependency injection, a lifecycle and multi-applet binaries around this engine.

Status#

v0 — the API is settling and may still move. Module path sxcli.dev/conf, Go 1.26+, licensed under Apache-2.0.