sxcli.devSimple Extensible CLI
You are viewing an archived snapshot (v0.2.0). Switch to the current version →

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:

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

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

go
go
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.",
            },
        },
    }))
  • Doc is the long-form field description; usage: stays the one-liner.
  • A non-empty Allowed declares 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 Hint declares what a value denotes, for tooling only — see below.
  • Metadata is validated with everything else, all at once: unknown field keys, an Allowed element 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:

AllowedHint
Meaningthe value must be one of thesethe value denotes this kind of thing
Frameworkenforces on every sourcenever enforces — data for tooling
Consumed byvalidation + completioncompletion, 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#

sh
sh
$ 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).

sh
sh
$ 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:

json
json
{
    "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:

yaml
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#

code
text
defaults  <  config files  <  environment  <  arguments

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