Repository navigation
Tracking: provenance, cross-flag relationships, and help grouping #88
Description
v4 release scope decision
This is a tracking issue, not an unimplemented provenance bug or a v4.0.0 blocker.
- Resolution provenance and explicitness shipped through Value provenance: track resolution stage and expose explicitness to handlers #90: handlers and derive/middleware callbacks receive
sources;wasExplicit()distinguishes explicit input even when it equals the declared default. - Declarative relationships and object-level validation: Declarative cross-flag relationships: typed .constrain() block and object-level .validate() seam #91, deferred to v4.x.
- Flag/argument grouping: Flag/arg grouping in help output and the definition document #92, deferred to v4.x.
Keep this umbrella open until its remaining children are complete. The original report below describes the older 3.0.1 behavior; its provenance and workaround claims no longer describe current v4. Existing .derive() plus sources remains the documented cross-flag workaround.
Original report / design draft (historical)
Two gaps that turn out to be the same gap: a flag knows nothing about how it was set, and nothing about the other flags. Everything cross-cutting therefore has to be hand-rolled imperatively in a handler, where it is untyped and invisible to help, the definition schema, and completions.
Part 1 — Resolved values carry no provenance
The resolver has an ordered, named chain — FLAG_RESOLUTION_ORDER = ['cli', 'env', 'config', 'prompt', 'default'] — and it already models sources on the failure path via FlagDiagnosticSource ({kind: 'env', envVar}, {kind: 'config', configPath}, {kind: 'prompt'}).
On the success path that is dropped. ResolveResult is {flags, args, deprecations}, and ActionParams narrows further to {args, flags, ctx, out, meta}. By the time a handler runs, the stage that won is gone.
A handler can observe what a flag resolved to, never whether the user said it.
Why this is a class of problem
- Mutually exclusive flags. Two flags answering the same question, where passing both should be a hard error. Today the only reachable behaviour is a silent precedence rule.
- Warning on ignored input. "You passed
--x, but it has no effect in this mode" — only honest if--xwas actually typed. - Explicit-wins merges. A command layering CLI values over its own project manifest (a
package.jsonfield, a framework config it reads itself) needs to know which CLI values were real. A defaulted value silently outranks the manifest. - Derived or conditional defaults. A default computed from another input — e.g. an output directory derived from a positional
<input>. Today the only way to express "unset" is to omit.default(). --explain-style diagnostics. Showing where each value came from, which is whatFLAG_RESOLUTION_ORDERalready names.
(1)–(4) need only a boolean. (5) wants the stage label.
Every workaround is lossy
A. Drop .default(), fall back in the handler (flags.output ?? 'favicon.ico').
Works, but the declared default leaves the schema: presence goes defaulted → optional and defaultValue disappears from the definition document, so --help --json and completions no longer know it. Help loses its (default: …) suffix, and the value gets restated in prose where nothing can consume it.
B. Compare against the default sentinel (flags.output !== 'favicon.ico').
Fails in exactly the case the check exists for: a user who explicitly passes a value equal to the default. A genuine conflict reads as "not provided".
C. .derive() — the workaround the docs currently recommend for mutual exclusion (Limitations → "Exactly One Of" Is Not Built In).
Worth flagging: this workaround is defeated by the same gap. .derive() runs after resolution, so it sees resolved values and cannot tell explicit from default either. The documented example only works because those flags carry no defaults —
flags.socket !== undefined // reliable only while `socket` has no .default()Add a default to any flag in an exclusivity rule and the recommended workaround silently stops detecting the conflict. The documented answer to limitation 1 depends on gap 1 not being hit.
Concrete case
kjanat/vite-svg-to-ico's generate has two flags that both answer what is this file called:
--output <name>— the ICO filename, defaultfavicon.ico--keep-name— derive it from the source instead (logo.svg→logo.ico)
Passing both should error. -o favicon.ico --keep-name is a real conflict that (B) cannot see and (C) cannot see, so it took (A) — --output lost its recorded defaultValue purely to make an unrelated conflict check possible. The declared-default surface and the explicitness check are currently mutually exclusive, and that trade shows up in tooling output as well as in handler code.
Part 2 — Relationships between flags are not expressible at all
The docs state this plainly:
There is no
.exclusive(), no.conflicts(), and no required-one-of group — flags are resolved independently of one another.
with the rationale:
- the schema models each flag as a standalone, independently resolved value;
- cross-flag rules are open-ended (exactly-one, at-most-one, all-or-none, implies), and a single declarative knob would cover only a slice of them.
The second point is correct about a single knob. An open-ended schema seam (shape E below) answers it directly: open-endedness is an argument for picking the right seam. The first point is an invariant worth keeping, and shapes D and E are built to keep it.
What is missing beyond exclusivity:
- conflicts —
--output×--keep-name - requires / implies —
--encodingis meaningless without--embed;--asset-dironly applies under--embed - exactly-one-of / at-most-one-of — transport selection
- all-or-none —
--certand--keytogether or neither - conditional requirement —
--tokenrequired only when--remoteis set
Today each is a hand-written if in .derive(). The if itself is fine. The cost is that the rule then exists only in the handler:
- help cannot say "conflicts with
--keep-name" - the definition schema /
--help --jsoncannot express it, so no downstream tool can - shell completions still offer a flag that conflicts with one already on the command line — dreamcli generates completions from the schema, so this is a payoff it is uniquely positioned to deliver and currently cannot
- error codes and messages are re-invented per project, with no consistency
That last consequence sits awkwardly against a value the same page states elsewhere, under "Shortcuts Like Automatic Negated Booleans Are Not Built In":
the library prefers schema-declared behavior over convention-heavy magic.
A rule that exists only as an if inside a handler is the least schema-declared construct available. It cannot be introspected, no consumer of the schema can see it, and every project reinvents its wording and error code. Declining to synthesize --no-verbose on the grounds that spellings should be explicitly declared, while cross-flag rules remain undeclarable at all, points in two directions at once. The consistent position is that relationships should be declarable too. The open question is which seam.
And: no flag/arg grouping in help
group(name: string): CommandBuilder exists, but it groups commands. There is no equivalent for flags or args — .help({flagOrder}) and sortFlags order a single flat Flags: table, they do not section it.
A command with a dozen flags spanning input, output, rasterizing, and diagnostics renders as one undifferentiated list. Grouping is the cheapest readability win available for wide commands, and it is schema data, so it should reach --help --json as well as the rendered text.
Possible shapes
Sketches only. They compose; E subsumes C/D for power users while C/D stay the ergonomic path.
A. Provenance on ActionParams
interface ResolveResult {
readonly flags: Readonly<Record<string, unknown>>;
readonly args: Readonly<Record<string, unknown>>;
readonly deprecations: readonly DeprecationWarning[];
readonly flagSources: Readonly<Record<string, FlagResolutionStage>>;
readonly argSources: Readonly<Record<string, ArgResolutionStage>>;
}.action(({ flags, sources }) => {
if (sources.flags.output === 'cli' && flags['keep-name']) { /* … */ }
});FlagResolutionStage / ArgResolutionStage would join the root exports — today only ResolveResult is re-exported from @kjanat/dreamcli, the stage types stay in core/resolve/contracts.
B. Narrower: an explicitness predicate
.action(({ flags, wasExplicit }) => {
if (wasExplicit('output') && flags['keep-name']) { /* … */ }
});Covers (1)–(4) without committing the stage vocabulary to the public API. Loses (5).
C. Flag-level relationship modifiers
.flag('keep-name', flag.boolean().conflicts('output'))
.flag('encoding', flag.enum([...]).requires('embed'))Reads best, but cannot be typed against sibling names: flag.boolean() builds in isolation and never sees the command's accumulated F, so 'output' is an unchecked string and a typo is a runtime surprise. Viable if untyped names are acceptable, or if the final .action() type-checks accumulated references.
D. Command-level constraint block — typed
Placing constraints after the .flag() calls means the full keyof F / keyof A is known, so names are compile-checked and typos fail the build:
command('generate')
.flag('output', flag.string())
.flag('keep-name', flag.boolean())
.flag('embed', flag.boolean())
.flag('encoding', flag.enum(['base64', 'utf8']))
.constrain((c) => [
c.conflicts('output', 'keep-name'),
c.requires('encoding', 'embed'),
c.exactlyOne('stdio', 'socket', 'node-ipc'),
c.allOrNone('cert', 'key'),
c.typo, // ← compile error
]);Each constraint is schema data, so help, --help --json, and completions can all read it, and violations carry framework-standard CLIError codes (CONFLICTING_FLAGS, MISSING_DEPENDENT_FLAG, …) that every project shares.
This keeps the architectural invariant intact. The stated objection — "the schema models each flag as a standalone, independently resolved value" — remains true. Constraints declared here sit as a layer over the schema: no flag learns about its siblings, resolution stays per-flag and independent, and the constraint pass runs afterwards with the resolved set in hand. That is also the deeper reason to prefer D over C — C pushes relationship knowledge down into individual flag builders, which is precisely what the invariant rules out. C's typing problem is a symptom of that violation.
E. Object-level Standard Schema — the open-ended seam
Standard Schema v1 support currently reaches individual values (flag.custom(schema), arg.custom(schema), per element for arrays/variadics). There is no seam for validating the resolved object as a whole — which is precisely the shape every cross-field rule needs.
command('generate')
.flag('output', flag.string())
.flag('keep-name', flag.boolean())
.validate(
z.object({ output: z.string().optional(), 'keep-name': z.boolean() })
.refine((v) => !(v.output !== undefined && v['keep-name']), {
message: '--output and --keep-name both name the ICO',
path: ['output'],
}),
);This directly answers the documented objection: exactly-one, at-most-one, all-or-none, implies, and anything else nobody has thought of are all just refinements, using validators consumers already depend on. No knob per rule. It preserves the same invariant as D — the schema is validated as a whole, after resolution, without any flag gaining knowledge of another.
StandardSchemaV1Issue.path maps onto flag names, so each issue can render against the flag it belongs to — StandardSchemaV1PathSegment is already exported, so the machinery exists.
Caveat worth designing around: an object-level schema sees resolved values, so on its own it hits the Part 1 wall too. It only fully works if the validated object carries provenance — e.g. validating {flags, args, sources}, or exposing explicitness to refinements some other way. Part 2 needs Part 1 underneath.
F. Flag/arg grouping
.flag('output', flag.string().group('Output'))
.flag('out-dir', flag.path().group('Output'))
.flag('sizes', sizesFlag().group('Rasterizing'))Output options:
-o, --output <string> …
-d, --out-dir <path> …
Rasterizing options:
-s, --sizes <number>... …
Ungrouped flags fall back to today's Flags: table, so it stays additive. Group order could follow declaration or an explicit .help({ groupOrder }), consistent with the existing flagOrder. As schema data it belongs in the definition document too.
Observed on npm:@kjanat/dreamcli@3.0.1.
Activity
coderabbitai commentedon Aug 1, 2026
🔗 Related PRs
#1 - feat: shell completions, config discovery, custom flags, deprecation, and nested subcommand trees (v0.5–v0.7) [closed]
#7 - Rework default commands, help subcommand, plugin hooks, and package discovery [closed]
#43 - Add eager
--completionsflag and.help()config builder [merged]#57 - feat: themed help output + negatable/duplicates/case-parity flags (rc.8) [merged]
#58 - feat: compile-time array-element narrowing + JSON help output [merged]
#74 - feat(help): configurable flag order in help output (
#68) [merged]📝 Issue Planner
Check the box below or use the
@coderabbitai plancommand to generate an implementation plan and prompts that you can use with your favorite coding assistant.🧪 Issue enrichment is currently in open beta.
To disable automatic issue enrichment, add the following to your
.coderabbit.yaml:💬 Have feedback or questions? Drop into our discord!
[-]Resolved values carry no provenance — a handler can't tell an explicit flag from its default[/-][+]Flags are resolved in isolation: no value provenance, no declarative relationships, no grouping[/+]docs(cli): render the same default suffix on both --output flags
docs(cli): render the same default suffix on both --output flags
docs(cli): render the same default suffix on both --output flags
[-]Flags are resolved in isolation: no value provenance, no declarative relationships, no grouping[/-][+]Tracking: provenance, cross-flag relationships, and help grouping[/+]