Your config struct
One struct per service is the whole configuration story: register it with
fw.WithConfig(&cfg), and the field values at registration are the
defaults. The core fills the same struct in place — there is never a
second config instance — and then calls Configured() as a notification.
The exhaustive example#
Every supported type and every annotation in one struct, for an applet
registered as mytool:
type Config struct { // strings and numbers — every int/uint width and both floats work Name string `json:"name" arg:"name,n" usage:"who this instance is"` Port uint16 `json:"port" arg:"port,p" usage:"listen port"` Ratio float64 `json:"ratio" arg:"ratio" usage:"mix ratio 0..1"` Retries int8 `json:"retries" arg:"retries" usage:"attempts before giving up"` // bool: bare flag sets true, --verbose=false unsets Verbose bool `json:"verbose" arg:"verbose,v" usage:"chatty diagnostics"` // duration: unit suffix required in EVERY source (5s, 90m, 1h30m) Timeout time.Duration `json:"timeout" arg:"timeout" usage:"request timeout"` // slices of any supported scalar Tags []string `json:"tags" arg:"tag" usage:"labels, repeatable"` Weights []float64 `json:"weights" arg:"weight" usage:"weights, repeatable"` // env tag only: settable via environment and file, no CLI clutter Token string `json:"token" env:"MYTOOL_TOKEN" usage:"deploy-time secret"` // env:"-" with arg: argument-only — the derived env var is suppressed DryRun bool `json:"dryRun" arg:"dry-run" env:"-" usage:"plan, don't act"` // no arg, no env: file-only Comment string `json:"comment"` // dump:"-": run-scoped — excluded from --write-config output and // REFUSED from config files (loud startup error if a file sets it) Unsafe bool `json:"unsafe" arg:"unsafe" dump:"-" usage:"skip safety checks"` // nested struct: allowed for file/JSON structure, but its fields are // file-only in v1 — no arg/env tags inside TLS TLSConfig `json:"tls"` } type TLSConfig struct { Cert string `json:"cert"` Key string `json:"key"` }
Supported types: string, bool, all int/uint widths, float32,
float64, time.Duration, and slices of these. The machinery operates
on kinds, so named types over a supported kind — type Level string,
type Port uint16 — work as-is. Nested structs are allowed as shown.
Unsupported kinds — maps, custom non-scalar types (your own time type,
say) — and embedded fields are registration errors, reported at startup
together with every other violation.
What each tag means#
| Tag | Meaning |
|---|---|
json:"key" | required on every exported field — the config-file key, nested under the service id |
arg:"long[,short]" | opt-in CLI argument; no tag → no argument |
env:"NAME" | explicit env var; env:"-" suppresses even the derived one; omitted → derived from the arg name |
usage:"…" | help text for --help, rendered through Tr() |
dump:"-" | run-scoped: excluded from generated configs, refused from config files |
A field with an arg tag and no env tag gets a derived environment
name: the applet id plus the long name, uppercased, dashes to underscores
— dry-run would derive MYTOOL_DRY_RUN (suppressed here by env:"-").
Note the two ids at play: config file keys nest under the service id,
while env prefixes come from the dispatched applet id.
Metadata and value domains#
Tags cover the wire format; fw.WithMetadata adds a declarative layer on
top — long-form documentation, advisory hints, and enforced value
domains:
fw.Register("mytool", m, fw.WithConfig(&m.cfg), fw.WithMetadata(&fw.Metadata{ Description: "Serves a directory over HTTP.", Fields: map[string]any{ // keyed by Go field name; "TLS.Cert" nests "Name": fw.FieldMetadata[string]{ Allowed: []string{"alice", "bob", "carol"}, Doc: "Instance identity, used in greetings and logs.", }, "Dir": fw.FieldMetadata[string]{ Hint: fw.HintDirectory, // advisory: it names a directory Doc: "Document root served over HTTP.", }, }, }))
Docis the long-form field description;usage:stays the one-liner.- A non-empty
Alloweddeclares a closed value domain, enforced by the framework on every source: an argument, environment variable or config-file value outside the set is a loud startup violation naming the source and the allowed values. Slice fields are checked per element, and a registered default outside its own declared domain is caught at registration. - A
Hintdeclares what a value denotes, for tooling only — see below. - Metadata is validated with everything else, all at once: unknown field
keys, an
Allowedelement type that doesn't match the field, field metadata on a config-less service — collected startup violations.
Enforced vs advisory#
Allowed and Hint sit in different trust classes, deliberately:
Allowed | Hint | |
|---|---|---|
| Meaning | the value must be one of these | the value denotes this kind of thing |
| Framework | enforces on every source | never enforces — data for tooling |
| Consumed by | validation + completion | completion, documentation |
The available hints are HintFile ("names a file, existing or to be
created"), HintDirectory, and HintServiceID ("names a service
registered in this binary" — completable from the Introspector).
A hint cannot be enforced, and that is the point: --config new.yaml
legitimately names a file that does not exist yet, and an unknown
service id gets a better error from resolution than a value check could
give. Hints are the advisory sibling of Allowed, in the same trust
class as Doc.
The core dogfoods the mechanism: its own --config declares
HintFile, and --disable/--enable declare HintServiceID.
(--override takes from=to pairs — no honest hint fits, so it
declares none.)
Two more collected violations here: a hint on a non-string field (paths
are strings), and a hint combined with a non-empty Allowed on the same
field — a closed enum and "it's a file" contradict each other, so
declare one.
Declare once, get it everywhere: the same declarations feed the
introspection surface (ArgInfo.Allowed, ArgInfo.Hint, Describe)
that completion and documentation services consume — see
Services & injection.
Setting values with arguments#
$ mytool --name alice -p 8080 --ratio=0.5 # --long value, --long=value, -s value $ mytool -v # bool: presence means true $ mytool --verbose=false # ...and =false unsets $ mytool -vn alice # bundling: all bools except the last $ mytool --tag a --tag b # slices: repetition appends $ mytool --timeout 1h30m # durations need a unit; '90' is rejected $ mytool --dry-run -- input.txt output.txt # -- ends flags; the rest is positional
Long names are lowercase, at least two characters, letter-first (letters/digits/dashes, no trailing dash); short forms are a single ASCII letter or digit. A duplicate long name anywhere in the closure is a startup error; short names are first-come, first-served.
Setting values from the environment#
Derived names for the example (applet mytool): MYTOOL_NAME,
MYTOOL_PORT, MYTOOL_RATIO, MYTOOL_RETRIES, MYTOOL_VERBOSE,
MYTOOL_TIMEOUT, MYTOOL_TAG, MYTOOL_WEIGHT — plus the explicit
MYTOOL_TOKEN. Note MYTOOL_TAG, singular: derivation uses the long
argument name (arg:"tag"), never the field or json name. DryRun
has none (env:"-"), Comment and the TLS fields have none
(file-only).
$ MYTOOL_PORT=8080 MYTOOL_VERBOSE=true mytool $ MYTOOL_TAG=a,b,c mytool # slices: comma-separated $ MYTOOL_TAG= mytool # empty value = empty slice (the only way from env) $ MYTOOL_TIMEOUT=90m mytool # unit suffix required here too
Explicit env names must be uppercase letters, digits and underscores, not digit-first; a duplicate explicit name across the closure is a startup error (derived names cannot collide because long names are unique).
Setting values in config files#
JSON is the native format — always enabled, nothing to import. Keys nest under the service id; durations are strings (never numbers); slices are arrays:
{ "mytool": { "name": "alice", "port": 8080, "ratio": 0.5, "verbose": true, "timeout": "1h30m", "tags": ["a", "b"], "weights": [0.25, 0.75], "token": "s3cr3t", "comment": "file-only field, happily set here", "tls": { "cert": "/etc/ssl/mytool.pem", "key": "/etc/ssl/mytool.key" } } }
Other formats come from format providers. With
_ "sxcli.dev/fw/configfmt/yaml" linked in, the same configuration as
YAML:
mytool: name: alice port: 8080 timeout: 1h30m tags: [a, b] tls: cert: /etc/ssl/mytool.pem key: /etc/ssl/mytool.key
Other formats are a small job to add yourself — the
format provider demo teaches the binary Java
.properties files in one page.
Two file-side rules worth repeating: a dump:"-" field (unsafe above)
appearing in a config file is a loud startup error, and so is any
unknown key — misconfiguration never passes silently.
Precedence#
defaults < config files < environment < argumentsMerging is field-by-field. For slices the rule is concrete: the first argument occurrence of a flag replaces whatever files or environment provided; further repetitions append to that.
Where files are searched is covered in
Config discovery & hardening; the --config
override and the rest of the core's surface live in
Core config & arguments.