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

Your config struct

One struct per service is the whole configuration story. A registration names the struct through an accessor, and the field values the factory sets 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.

go
go
fw.NewRegistration(ID,
    func() *Mytool { return &Mytool{cfg: Config{Version: 1, Port: 8080}} },
    func(m *Mytool) *Config { return &m.cfg }, // the accessor binds the struct
).Alias("mytool").Register()

Every schema is versioned#

A config struct must declare a version, from its very first release:

go
go
Version uint32 `json:"version"`

The requirement is a top-level uint32 field tagged json:"version" — and it may carry only that tag; conf, env, usage and dump on it are all errors. Version 0 is rejected: versions start at 1.

This is the price of admission for schemas that can evolve without breaking deployed config files. What that buys you is on Config migrations.

The exhaustive example#

Every supported type and annotation in one struct, for an applet whose alias is mytool:

go
go
type Config struct {
    Version uint32 `json:"version"` // required, tag-only, starts at 1

    // strings and numbers — every int/uint width and both floats work
    Name    string  `json:"name"    conf:"name,n"    usage:"who this instance is"`
    Port    uint16  `json:"port"    conf:"port,p"    usage:"listen port"`
    Ratio   float64 `json:"ratio"   conf:"ratio"     usage:"mix ratio 0..1"`
    Retries int8    `json:"retries" conf:"retries"   usage:"attempts before giving up"`

    // bool: bare flag sets true, --verbose=false unsets
    Verbose bool `json:"verbose" conf:"verbose,v" usage:"chatty diagnostics"`

    // duration: unit suffix required in EVERY source (5s, 90m, 1h30m)
    Timeout time.Duration `json:"timeout" conf:"timeout" usage:"request timeout"`

    // slices of any supported scalar
    Tags    []string  `json:"tags"    conf:"tag"    usage:"labels, repeatable"`
    Weights []float64 `json:"weights" conf:"weight" usage:"weights, repeatable"`

    // env tag only: settable via environment and file, never on argv —
    // where secrets belong
    Token string `json:"token" env:"MYTOOL_TOKEN" usage:"deploy-time secret"`

    // env:"-" with conf: argument-only, the derived env var suppressed
    DryRun bool `json:"dryRun" conf:"dry-run" env:"-" usage:"plan, don't act"`

    // no conf, 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" conf:"unsafe" dump:"-" usage:"skip safety checks"`

    // positionals: declared, validated, and shown in --help
    Pattern string   `json:"pattern" pos:"0"    usage:"the search pattern"`
    Files   []string `json:"files"   pos:"rest" usage:"files to search"`

    // nested struct: file and environment, but no conf tags below the top
    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. Unsupported kinds — maps, custom non-scalar types — 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, and the source of derived env segments
conf:"long[,short]"the one operator name: grants --long value, --long=value, -s value, and feeds the derived env var
env:"NAME"explicit env var, verbatim; env:"-" opts out entirely; omitted → derived
usage:"…"help text for --help, rendered through Tr()
pos:"0" / pos:"rest"bind a positional argument instead of a flag
dump:"-"run-scoped: excluded from generated configs, refused from config files

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, and the loser simply keeps its long form.

The arg: tag is gone. conf: replaces it, and it is not a silent rename: a leftover arg: tag is a startup violation telling you to rename it. One tag now names the flag and derives the env var, so there is a single operator name per field instead of two that could drift apart.

Positional arguments#

Positionals are part of the contract rather than a bag of leftover strings. pos:"N" binds an indexed scalar — required unless you write pos:"N,optional" — and pos:"rest" collects the tail into a slice:

sh
sh
$ mytool 'error' access.log app.log    # Pattern="error", Files=[access.log app.log]

The rules are checked at startup: indices contiguous from zero, no duplicates, at most one rest, and no required index after an optional one. A missing required positional and an unclaimed surplus are both loud errors, and --help renders the shape — <pattern>, <mode> (optional), <files...>.

Only applets may declare positionals. A plain service with a pos: tag is a registration error — it never reaches startup. Flags and env vars are namespaced per service, so any number of services can own their own; the positional tail has no namespace. There is exactly one command line, and it belongs to the applet being run. If two services in a closure could both claim argument 0, the winner would depend on composition order — precisely the class of silent, order-dependent behaviour this release exists to remove.

When a service genuinely needs a value that arrives positionally, the applet declares it and hands it over — as a normal injected dependency, in the open.

Positionals are also only bound for the applet actually being run: another applet's declarations lie dormant in the same binary, so a multi-applet tool has one positional contract per applet rather than a merged one.

Being positional implies run-scoped — no env var, refused from config files. fw.Positionals() is gone; the tail is now typed struct fields like everything else.

Nested structs#

Nested fields participate in files and the environment, but conf: tags are not allowed below the top level — the error tells you to mirror the value into a top-level field yourself. A flag namespace that nests arbitrarily deep produces names nobody wants to type; the flat operator surface is deliberate.

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 -- 'error' input.txt       # -- ends flags; the rest is positional

Setting values from the environment#

A field with a conf: tag derives ALIAS__LONG — the alias of the applet being run, two underscores, then the long name with dashes folded to single underscores:

sh
sh
$ MYTOOL__NAME=alice MYTOOL__PORT=8080 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

Note MYTOOL__TAG, singular: derivation uses the conf: long name, never the field or json name. DryRun has none (env:"-"), and Token uses its explicit MYTOOL_TOKEN verbatim.

A field without a conf: tag derives from its json path instead, qualified by the owning service's section: ALIAS__SECTION__PATH. So TLS.Cert on a service whose section is filesink, run under applet alias cat, is CAT__FILESINK__TLS__CERT. When the section and the running alias are the same, that middle segment is dropped — MYTOOL__TLS__CERT here.

The double underscore is a structural boundary, which is why names can never contain a run of separators: a--b is rejected everywhere, so a name cannot forge a boundary.

Setting values in config files#

JSON is the native format — always enabled, nothing to accept. Each service's keys live in a section named after its alias, carrying the schema version; durations are strings (never numbers); slices are arrays:

json
json
{
    "mytool": {
        "version": 1,
        "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 configfmt/yaml accepted into the composition, the same configuration as YAML:

yaml
yaml
mytool:
    version: 1
    name: alice
    port: 8080
    timeout: 1h30m
    tags: [a, b]
    tls:
        cert: /etc/ssl/mytool.pem
        key: /etc/ssl/mytool.key

Adding a format is a small job — the format provider demo teaches the binary Java .properties files in one page.

Three file-side rules worth repeating: a dump:"-" field and any positional appearing in a config file are loud startup errors, and so is any unknown key — misconfiguration never passes silently.

Metadata and value domains#

Tags cover the wire format; Metadata adds a declarative layer on top — long-form documentation, advisory hints, and enforced value domains:

go
go
fw.NewRegistration(ID, newMytool, configOf).
    Alias("mytool").
    Metadata(&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.",
            },
        },
    }).
    Register()
  • 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: arguments, environment, config files, positionals and even migrated output are all checked, and a 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 too.
  • A Hint declares what a value denotes, for tooling only.
  • 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.

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.

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.