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#
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:
$ 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:
{"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#
struct defaults < config files < environment < argumentsFiles are discovered in three tiers, merged in this order — later overrides earlier:
| Tier | Path |
|---|---|
| companion | <binary-dir>/<name>-config.<ext>, next to the real binary (symlinks resolved) |
| system | /etc/<name>/config.<ext>, or %ProgramData%\<name>\ on Windows |
| user | os.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:
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:
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#
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:
- arguments —
FeatureConfig,FeatureWriteConfig,FeatureHelp,FeatureValidateConfig,FeatureUpgradeConfig(which takes--from-versionwith it — that flag is inert alone). - search tiers —
FeatureCompanionConfig,FeatureSystemConfig,FeatureUserConfig. These apply to the standard search; a caller-suppliedSourcesis yours to define. - the environment —
FeatureEnvironmentkills the whole source, and note that means every binding: explicitenv:"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 terminalLoadandResult.engine— the machinery: schema extraction, parsers, file discovery, source merging. This is what a framework builds on;sxcli.dev/fwis its first consumer.fail— the shared violation collector: record every problem, fail once listing all of them.yaml— the ready-made YAML format provider.
Related#
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.