Skip to content

flagset

A named set of flag declarations that any command can pull in with use.

kdl
name "demo"
bin "demo"

flagset "output" {
  flag "-v --verbose" help="Print more"
  flag "--json" help="Machine-readable output"
}

cmd "build" {
  use "output"
  flag "--release"
}

cmd "test" {
  use "output"
  flag "--filter <pattern>"
}

build and test each end up with three ordinary flags. A flagset is resolved while the spec is read, so nothing downstream — help, completions, docs, manpages, the generated parsers — sees a new concept. It is the spec's answer to the same repetition flatten removes on the typed side.

Where each node goes

flagset is declared at the top level of a spec, never inside a command: a set one command can see and its sibling cannot is a scoping rule to explain for no benefit.

use goes wherever the flags themselves would go — at the top level, inside any cmd at any depth, or inside another flagset. It takes one or more set names:

kdl
name "demo"
bin "demo"

flagset "logging" {
  flag "-v --verbose"
}
flagset "output" {
  flag "--json"
}

// the root's own flags
use "logging"

cmd "build" {
  use "logging" "output"
}

Order does not matter: the whole file is read before any use is resolved, so a set may be declared below the command that uses it.

Expansion happens in place

A use between two flags expands between them. Help order is spec order, so a set that always appended would reorder the command that used it:

kdl
name "demo"
bin "demo"

flagset "output" {
  flag "--json"
}

cmd "build" {
  flag "--release"
  use "output"
  flag "--target <triple>"
}

build's flags are --release, --json, --target, in that order.

The nearer declaration wins

A command that declares a flag the set also declares keeps its own, matching what happens when a subcommand redeclares a global:

kdl
name "demo"
bin "demo"

flagset "output" {
  flag "--json" help="Machine-readable output"
  flag "-q --quiet"
}

cmd "build" {
  use "output"
  flag "--json" help="JSON build report, with a schema"
}

build gets its own --json and the set's -q --quiet. Overlap is measured per form, the way it is everywhere else in a spec: a command declaring -j --job-count takes the set's -j --jobs out of the expansion, because -j is already spoken for.

Sets compose

A flagset may use another, so a large set can be assembled from small ones:

kdl
name "demo"
bin "demo"

flagset "common" {
  flag "-v --verbose"
}
flagset "output" {
  use "common"
  flag "--json"
}
flagset "input" {
  use "common"
  flag "--stdin"
}

cmd "run" {
  use "output" "input"
}

run gets -v --verbose once — a set reachable twice through composition contributes once, by the same per-form rule above. A cycle is an error, reported as the path that closes it (flagset cycle: a -> b -> a), and so is a use naming a set that does not exist.

Shared sets in their own file

Sets travel through include, which is how a CLI with several specs keeps its shared declarations in one place:

kdl
// common.usage.kdl
flagset "common" {
  flag "-v --verbose"
  flag "--config <FILE>"
}
kdl
// demo.usage.kdl
name "demo"
bin "demo"
include file="./common.usage.kdl"

cmd "build" {
  use "common"
}

Each file resolves its own use nodes against the sets it declares and the ones it includes. A file cannot use a set declared only by a file that includes it — that would make the meaning of a spec depend on who read it — so an unresolved name is an error in the file that wrote it.

Every set is resolved while its file is read, whether or not anything uses it, so a use naming a set that does not exist is reported even inside a set nothing has reached for yet.

A name may still be declared only once, and an include does not make that a choice: two files declaring common is an error wherever the include stands, rather than one of them quietly taking the name from the other. What is counted is declarations rather than arrivals, so the shared file above may be included by every file that uses its sets and by the spec that includes those — one declaration reaching a spec by several routes is what this feature is for.

A use belongs to the flags it stands among, so an included file that declares flags of its own for the same command replaces the use along with them — the same rule group follows, and for the same reason: whoever owns the flag list owns what is said about it.

What a flagset is not

Not global. A global flag is declared once and inherited by every subcommand automatically. Reach for a flagset when inheritance is the wrong shape: a set that belongs to some commands and not others, or one whose flags should be answered by each command rather than by the root.

Not a group. A group states a relationship between flags — at most one, exactly one — and is enforced when a command line is parsed. A flagset states nothing about the flags it holds; it is only a way to declare them once. A set's flags may of course be named by a group on the command that used them.

Not for arguments. A flag is identified by its spelling wherever it lands, so the same declaration means the same thing under any command. A positional is identified by its position, so the same set spliced into two commands with different arguments would mean two different things. Declaring arg inside a flagset is an error rather than a guess.

What the derive emits

The typed Rust side has its own way to share declarations — #[usage(flatten)] — and it lowers into this node: a flattened struct becomes one flagset named after it, and every command that flattens the struct gets a use. So a CLI written in Rust and a CLI written as a spec say the same thing the same way, which is the point of the node existing on both sides.

Two flattened structs whose names end in the same word cannot both have the name, so neither gets it and both are written inline. That is never wrong, only longer — but it is worth knowing if a set you expected did not appear.

One-way

Expansion is part of reading the file. A spec that is parsed and re-emitted — by usage g json, by a generator, by anything that prints a spec back out — contains the flags, not the flagset and use nodes that produced them, the same way include does not survive a round trip. What a flagset saves is authoring and review, not bytes on the wire.

Because flagset and use are spec nodes, a spec using them needs a usage new enough to know them. Say so with min_usage_version if your spec is read by tooling you do not control.

MIT LicenseCopyright © 2026jdx.dev