.NET CLI argument parsing
Choosing and implementing CLI args for .NET console apps and global dotnet tools
(PackAsTool). State verified 2026-08-04 against primary sources; full worked example:
the repo's CLI-args design doc (docs/plans/cli-args-parsing.md).
Parser landscape (as of 2026-08)
| Option |
Status |
Verdict |
| System.CommandLine 2.0.x |
GA Nov 2025, 2.0.10 current, MIT, net8.0 asset (zero deps), used by dotnet CLI, trim-friendly |
default choice for flat option sets |
| Spectre.Console.Cli |
0.55.0 stable, still 1.0.0-alpha; very active |
good, but a command framework (DI, nested commands) — overkill for ~10 flat options |
| Microsoft.Extensions.Configuration.CommandLine + EnvironmentVariables |
ship in the ASP.NET Core shared framework (zero new packages); CreateBuilder(args) already layers CLI > env > appsettings |
key-value ONLY — not a parser: unknown --keys silently stored, no --help/validation, trailing valueless --key dropped, --key --other consumes next token as value |
| Cocona |
archived 2024 |
do not adopt |
| McMaster.Extensions.CommandLineUtils |
maintained, minimal |
third-party, thinner than the official parser |
| hand-rolled |
— |
rejected wherever 'official NuGet over hand-rolled' applies |
Maintaining an existing Cocona tool (verified 2026-08-05, the reference tool)
Cocona is archived — don't adopt it for new work — but tools already built on it
need these behaviors (Cocona 2.2.0, observed on a real CLI):
- Exceptions escaping a command handler do NOT propagate to your code: Cocona's
internal
HandleExceptionAndExitMiddleware catches them, prints the FULL
exception (ex.ToString(), stack trace included) to stderr, and returns exit
code 1. A try/catch around consoleApp.Run() in Program.cs never fires — it
is dead code.
- Setting
Environment.ExitCode inside a handler is clobbered: the dispatcher
writes the exit code from the handler result (0 on success).
- Working pattern for clean errors + exit codes: make the handler lambdas return
Task<int> — Cocona uses an int-returning handler's value as the process exit
code. Wrap each command: static async Task<int> RunAndReportErrors(Func<Task> action)
{ try { await action(); return 0; } catch (Exception ex) { Console.Error.WriteLine($"Error: {ex.Message}"); return 1; } }.
CoconaAppOptions (2.2.0) has NO exception-handler hook (verified by
reflection) — no built-in message-only error mode; the wrapper above is the way.
The protocol-stream trap (stdio MCP / JSON-RPC daemons)
- System.CommandLine renders help to stdout by default (
InvocationConfiguration.Output
→ Console.Out); only parse errors default to stderr (InvocationConfiguration.Error
→ Console.Error). Both are settable from the parse result.
- If stdout carries a protocol (stdio MCP), ALL CLI text — help, version, errors — must go
to stderr. Do parse-first:
root.Parse(args, new ParserConfiguration { EnablePosixBundling = true }),
inspect result.Errors and result.Action, render through a writer you pass in
(Console.Error) via result.Invoke(new InvocationConfiguration { Output = writer, Error = writer }).
Never invoke the default invoke path that prints help to stdout: a client spawning the
tool with bad args would get help frames mixed into the protocol stream.
- Lock it with a test: help/version/parse-error renderings write ZERO bytes to the stdout
writer.
System.CommandLine 2.0.10 GA — pinned idioms (verified 2026-08-04)
Full verified detail (ctor signatures, exact error messages, probe transcripts):
Read references/system-commandline-2.0.10-ga-api.md when pinning 2.0.10 GA API facts; instance-based reads + custom
value-grammar validation via Option.Validators (verified 2026-08-06):
Read references/system-commandline-2.0.10-option-validation.md when validation ordering matters. The short version:
- GA has NO
CommandLineBuilder, no Builder namespace, no UseDefaults() — every
pre-GA post and older skill text showing new CommandLineBuilder(root).UseDefaults().Build()
is the beta API. Parse directly: root.Parse(args, new ParserConfiguration { EnablePosixBundling = true }).
- Help:
parseResult.Action is HelpAction (System.CommandLine.Help). Version:
parseResult.Action?.GetType().Name == "VersionOptionAction" — VersionOption.VersionOptionAction
is an inaccessible nested type, so the is pattern does not compile (CS0122); it is also
absent from the package XML docs.
- Errors:
parseResult.Errors (ParseError.Message); OptionResult.GetValueOrDefault<T>()
THROWS on invalid enum values → read values only when Errors.Count == 0.
- Duplicate single-value options are a PARSE ERROR ("Option '--x' expects a single argument
but 2 were provided"), NOT last-wins (beta behavior; no switch restores last-wins).
--help --bogus → help wins, 0 errors.
- Render:
parseResult.Invoke(new InvocationConfiguration { Output = writer, Error = writer })
→ exit 0 for help/version, 1 for parse errors (error text + help both to the writers).
- Values:
((OptionResult)parseResult.GetResult("--name")!).GetValueOrDefault<T>() —
GetResult(string) returns SymbolResult (needs the cast); use presence (GetResult not
null) to distinguish "option not given" from the default value. Enum ToString() is
PascalCase ("Http") — lower it if your contract says "https".
- Instance-based reads are the duplicate-alias fix (verified 2026-08-06): GA has
ParseResult.GetValue<T>(Option<T>) — GetValueForOption is the PRE-GA name and does
NOT compile against 2.0.10 (CS1061). Root and a subcommand may both declare the same
alias (--port on root AND on serve; parses fine, help renders both), but name-based
GetResult("--port") resolves the ROOT option (DFS order) when both are given — the
subcommand's value is unreachable by name. Expose the subcommand's Option instances as
internal static readonly fields and read parseResult.GetValue(optionInstance)
(defaults via DefaultValueFactory = _ => value are honored: absent → default).
- Custom value grammar:
Option.Validators is the ONLY native parse-error hook in GA
(no ParseArgument). The validator delegate receives the OptionResult; read the raw
token with result.GetValueOrDefault<string>() (null when absent) and report failure via
result.AddError("...") — there is NO ErrorMessage property in GA (CS1061). Pattern:
Option<string> + a pure static TryParse + validator, e.g. SCL's built-in TimeSpan
converter exists but lacks 4h sugar, so --idle-timeout stays a string option parsed
by a custom span parser. Pin the invalid-value case with a parse test (Errors not empty).
- Pure span parsers must be total:
TimeSpan.FromDays(int) throws
ArgumentOutOfRangeException (NOT OverflowException) on overflow — a TryParse that
wraps From* must catch it (or bounds-check) or "999999999d" throws instead of
returning false. Add an overflow row to the invalid-input theory.
Option<T> ctor is (string name, string[] aliases) — no description parameter (set via
property); help placeholder via HelpName (gives --transport <stdio|http|https>);
hide via Symbol.Hidden; RootCommand.ExecutableName is a read-only STATIC property.
- GA help format is "Description:" / "Usage:" / "Options:" (NOT "USAGE:"), built-in help
aliases
-?, -h, --help; --version prints the ENTRY assembly's informational version —
in a test host that is the test runner, so assert routing only and prove the real version
via the packed-tool smoke gate.
Re-verifying the pins when the package version changes
The pins above are for 2.0.10. When the package bumps, re-pin EMPIRICALLY instead of
trusting this doc: build a throwaway console project (restore hits the NuGet cache, so it's
fast) that parses a battery of arg sets and prints parseResult.Action?.GetType().FullName,
error count, showHelp/showVersion, and the Invoke exit code. Starter:
templates/system-commandline-idiom-probe/ (copy into /tmp — never into the repo under
review). Probe pitfalls that cost real time:
- Fastest existence check, no decompiler: grep the shipped package XML docs —
grep -o 'member name="[MTPS]:System.CommandLine.Parsing.ParseResult.[^"]*"' ~/.nuget/packages/system.commandline/<ver>/lib/net8.0/System.CommandLine.xml
(swap the type for any API surface; grep -A8 'M:System.CommandLine.Parsing.SymbolResult.AddError'
for the doc text). Member existence — and the exact GA name — resolved in seconds.
Assembly.GetType("System.CommandLine.VersionOptionAction") returns null — the type
is NESTED (System.CommandLine.VersionOption+VersionOptionAction, note the +). The
instance's GetType().Name is still just "VersionOptionAction", which is why the name
pin works.
someType is HelpAction where someType is a Type object is ALWAYS false (CS0184
warning) — test the ACTION INSTANCE (parseResult.Action is HelpAction), never
parseResult.Action?.GetType() is ....
- A bare SDK-style csproj has no implicit usings —
Console/TextWriter fail to resolve
until you add <ImplicitUsings>enable</ImplicitUsings>.
- After the bump, re-run the E2E suite too:
--help --bogus (help-wins), duplicate-option
(error, not last-wins), and the WebApplicationFactory hidden-flag triplet are the three
behaviors most likely to shift between versions.
WebApplicationFactory trap (parse-first entry points)
WebApplicationFactory<T> invokes the REAL entry point with
--environment=Development --contentRoot=<projdir> --applicationName=<AssemblyName>.
Parse-first code rejects these as unknown options → early return → "The entry point exited
without ever building an IHost." → every E2E test fails (the old CreateBuilder(args) path
silently stored them as config keys, which is why this only bites after switching to
parse-first + CreateBuilder([])). Fix: declare the three as HIDDEN no-op options
(new Option<string>("--environment") { Hidden = true }, likewise --contentRoot,
--applicationName), values intentionally never consumed, and pin with a test that the
triplet parses with zero errors and yields no options.
UseSetting IS an arg-injection path (verified 2026-08-04 with a throwaway probe).
builder.UseSetting("key", "value") inside ConfigureWebHost is rendered as a
--key=value argument to the REAL entry point's Program.Main (WebApplicationFactory's
DeferredHostBuilder flattens its host configuration into args; captured verbatim:
--transport=http --environment=Development --dataRoot=/tmp/xxx --contentRoot=... --applicationName=...).
This is the escape hatch when a refactor removes env vars: E2E factories can still
inject CLI-only launch flags (UseSetting("data-root", tempRoot) → --data-root=<temp>).
The earlier claim "WebApplicationFactory can't inject args anyway" is WRONG — the
probe recipe (a throwaway web Probe project + Probe.Tests capturing Program.Main's
args into a static, dotnet test once) is how to re-verify when the SDK version changes.
Optional subcommands: a root with children REQUIRES a verb (verified 2026-08-04)
A RootCommand (or any Command) that has subcommands cannot be invoked bare: parsing
--flag value with no verb yields the parse error "Required command was not provided."
— there is NO switch making subcommands optional in 2.0.10 GA. The
"bare invocation runs the daemon, verbs run commands" pattern: build TWO roots over the
same launch options — the full tree (flags + verbs) and a launch-only root (flags only).
Parse the full tree first (help/version render from it, so --help lists the verbs);
when it has errors AND a token pre-scan (skip --opt + its value / --opt=value,
then look for known verb tokens) finds no verb, re-parse against the launch-only root
(genuine option errors re-surface there). Pin with tests: bare flags parse with zero
errors; a verb without its subcommand still errors.
Precedence and wiring
- CLI > env > default is the ASP.NET Core default layering (AspNetCore.Docs: command
line, env vars, user secrets (dev), appsettings.{ENV}.json, appsettings.json).
- If you parse args yourself, pass
[] to WebApplication.CreateBuilder([]) so the
built-in CommandLine config provider doesn't re-parse consumed flags into
IConfiguration (and doesn't silently accept typos as config keys).
- Implement the merge as one pure class:
cliValue ?? envValue ?? default, keeping env a
first-class middle layer. When a single-channel ruling removes env entirely, keep the
same pure-class shape minus readEnv. Bonus: env-mutating E2E factories
(WebApplicationFactory) keep working without migration — and when env is gone,
builder.UseSetting(...) in ConfigureWebHost still injects launch flags as
--key=value entry args (see the WebApplicationFactory trap section above).
~ expansion: only path options, done in the merge layer.
Config-verb trees: read references/config-verb-trees.md when building one-shot config-verb commands over the bank.
Secrets policy
- Never declare secrets as CLI options: args are visible in process listings and client
config files (
.mcp.json may be tracked/shared). Undeclared options fail with the
parser's unknown-option error — that error IS the defense.
- Test it: parser rejects
--<secret-flag> as unknown.
- Owner rulings can override this (2026-08-04: secrets moved into the settings
table — DB encrypted at rest). FINAL state (2026-08-05):
model set openai --api-key
remains a documented option on that one command; sync add s3 credentials are
INTERACTIVE — the --access-key/--secret-key options were deleted. Interactive-entry
pattern that worked: RunAsync(commandPath, parseResult, store, stdout, stderr, stdin, ct) gains a TextReader stdin (Program.cs passes Console.In; tests pass
StringReader/TextReader.Null — every call site ripples: BDD steps, E2E host, unit
helpers). The command prompts on STDERR (S3 access key (empty aborts): ), reads one
line, trims, and on empty input writes <tool>: <name> key required — … to stderr,
returns exit 1, and persists NOTHING (prompt before any settings write). Pin with tests:
happy path via StringReader; empty stdin → exit 1 + zero settings rows. Prefer
interactive entry over secret flags when a ruling demands secrets-in-settings; a kept
flag goes on the specific config command only (never the root), stays out of tracked
example files, and the argv-visibility tradeoff is flagged in the report.
Client config conventions (MCP global tools)
.mcp.json: {"mcpServers":{"<name>":{"command":"<tool-name>","args":[...],"env":{...}}}} —
dotnet tools appear as bare command names (e.g. "command": "the project"), unlike
npx/dotnet-run forms.
- Zero-config entry works when defaults are: stdio transport, user-scoped data dir,
rw access mode.
- MCP registry manifest (
server.json, schema 2025-10-17): packageArguments (named
--flag={value} or positional, {var} substitution) is a separate channel from
environmentVariables — keep args static/[] and secrets in environmentVariables.
Research technique: primary-source facts via curl
When web_extract is unavailable (search-only backend), gather package/repo facts directly —
versions via the NuGet flatcontainer API, metadata via the catalog, repo status via the
GitHub API, behavior from raw source. Exact recipe + verified 2026-08 facts with citations:
Read references/nuget-evidence-research.md when researching NuGet package facts.
Gotchas
- In stdio-MCP tools, help/version must render to stderr — stdout is the protocol channel.
- A root with children REQUIRES a verb — an optional-subcommand root silently rejects bare invocations.
References
references/system-commandline-2.0.10-ga-api.md — System.CommandLine 2.0.10 GA API surface notes; read when the 2.0.10 API surface is in question.
references/system-commandline-2.0.10-option-validation.md — Option.Validators and validation ordering; read when Option.Validators ordering matters.
references/system-commandline-2.0.10-subcommand-options.md — subcommand option scoping and parse results; read when scoping subcommand options.
references/system-commandline-bool-flags-and-prescan.md — bool-flag parsing traps and the pre-scan pattern for help/version detection; read when writing bool-flag pre-scans.
references/nuget-evidence-research.md — NuGet package/API facts without docs: flatcontainer, catalog, raw source; read when researching NuGet facts without docs.
1---2name: dotnet-system-commandline3description: Use when adding CLI argument parsing to a .NET app or dotnet tool: System.CommandLine 2.0.x GA idioms (parse-first, HelpAction/VersionOptionAction detection, Option.Validators, FromAmong), parser-landscape verdicts (Cocona archived — don't adopt), and the stdio-MCP trap where help/version must render to stderr. Includes Cocona-maintenance guidance for existing tools.4license: MIT5---67# .NET CLI argument parsing89Choosing and implementing CLI args for .NET console apps and global dotnet tools10(PackAsTool). State verified 2026-08-04 against primary sources; full worked example:11the repo's CLI-args design doc (`docs/plans/cli-args-parsing.md`).1213## Parser landscape (as of 2026-08)1415| Option | Status | Verdict |16|---|---|---|17| System.CommandLine 2.0.x | GA Nov 2025, 2.0.10 current, MIT, net8.0 asset (zero deps), used by dotnet CLI, trim-friendly | **default choice** for flat option sets |18| Spectre.Console.Cli | 0.55.0 stable, still 1.0.0-alpha; very active | good, but a command framework (DI, nested commands) — overkill for ~10 flat options |19| Microsoft.Extensions.Configuration.CommandLine + EnvironmentVariables | ship in the ASP.NET Core shared framework (zero new packages); `CreateBuilder(args)` already layers CLI > env > appsettings | key-value ONLY — not a parser: unknown `--keys` silently stored, no --help/validation, trailing valueless `--key` dropped, `--key --other` consumes next token as value |20| Cocona | archived 2024 | do not adopt |21| McMaster.Extensions.CommandLineUtils | maintained, minimal | third-party, thinner than the official parser |22| hand-rolled | — | rejected wherever 'official NuGet over hand-rolled' applies |2324## Maintaining an existing Cocona tool (verified 2026-08-05, the reference tool)2526Cocona is archived — don't adopt it for new work — but tools already built on it27need these behaviors (Cocona 2.2.0, observed on a real CLI):2829- Exceptions escaping a command handler do NOT propagate to your code: Cocona's30 internal `HandleExceptionAndExitMiddleware` catches them, prints the FULL31 exception (`ex.ToString()`, stack trace included) to stderr, and returns exit32 code 1. A `try/catch` around `consoleApp.Run()` in Program.cs never fires — it33 is dead code.34- Setting `Environment.ExitCode` inside a handler is clobbered: the dispatcher35 writes the exit code from the handler result (0 on success).36- Working pattern for clean errors + exit codes: make the handler lambdas return37 `Task<int>` — Cocona uses an int-returning handler's value as the process exit38 code. Wrap each command: `static async Task<int> RunAndReportErrors(Func<Task> action)`39 `{ try { await action(); return 0; } catch (Exception ex) { Console.Error.WriteLine($"Error: {ex.Message}"); return 1; } }`.40- `CoconaAppOptions` (2.2.0) has NO exception-handler hook (verified by41 reflection) — no built-in message-only error mode; the wrapper above is the way.4243## The protocol-stream trap (stdio MCP / JSON-RPC daemons)4445- System.CommandLine renders **help to stdout by default** (`InvocationConfiguration.Output`46 → `Console.Out`); only parse **errors** default to stderr (`InvocationConfiguration.Error`47 → `Console.Error`). Both are settable from the parse result.48- If stdout carries a protocol (stdio MCP), ALL CLI text — help, version, errors — must go49 to stderr. Do parse-first: `root.Parse(args, new ParserConfiguration { EnablePosixBundling = true })`,50 inspect `result.Errors` and `result.Action`, render through a writer you pass in51 (Console.Error) via `result.Invoke(new InvocationConfiguration { Output = writer, Error = writer })`.52 Never invoke the default invoke path that prints help to stdout: a client spawning the53 tool with bad args would get help frames mixed into the protocol stream.54- Lock it with a test: help/version/parse-error renderings write ZERO bytes to the stdout55 writer.5657## System.CommandLine 2.0.10 GA — pinned idioms (verified 2026-08-04)5859Full verified detail (ctor signatures, exact error messages, probe transcripts):60Read `references/system-commandline-2.0.10-ga-api.md` when pinning 2.0.10 GA API facts; instance-based reads + custom61value-grammar validation via `Option.Validators` (verified 2026-08-06):62Read `references/system-commandline-2.0.10-option-validation.md` when validation ordering matters. The short version:6364- **GA has NO `CommandLineBuilder`, no `Builder` namespace, no `UseDefaults()`** — every65 pre-GA post and older skill text showing `new CommandLineBuilder(root).UseDefaults().Build()`66 is the beta API. Parse directly: `root.Parse(args, new ParserConfiguration { EnablePosixBundling = true })`.67- Help: `parseResult.Action is HelpAction` (`System.CommandLine.Help`). Version:68 `parseResult.Action?.GetType().Name == "VersionOptionAction"` — `VersionOption.VersionOptionAction`69 is an inaccessible nested type, so the `is` pattern does not compile (CS0122); it is also70 absent from the package XML docs.71- Errors: `parseResult.Errors` (`ParseError.Message`); `OptionResult.GetValueOrDefault<T>()`72 THROWS on invalid enum values → read values only when `Errors.Count == 0`.73- Duplicate single-value options are a PARSE ERROR ("Option '--x' expects a single argument74 but 2 were provided"), NOT last-wins (beta behavior; no switch restores last-wins).75 `--help --bogus` → help wins, 0 errors.76- Render: `parseResult.Invoke(new InvocationConfiguration { Output = writer, Error = writer })`77 → exit 0 for help/version, 1 for parse errors (error text + help both to the writers).78- Values: `((OptionResult)parseResult.GetResult("--name")!).GetValueOrDefault<T>()` —79 `GetResult(string)` returns `SymbolResult` (needs the cast); use presence (`GetResult` not80 null) to distinguish "option not given" from the default value. Enum `ToString()` is81 PascalCase ("Http") — lower it if your contract says "https".82- **Instance-based reads are the duplicate-alias fix (verified 2026-08-06):** GA has83 `ParseResult.GetValue<T>(Option<T>)` — `GetValueForOption` is the PRE-GA name and does84 NOT compile against 2.0.10 (CS1061). Root and a subcommand may both declare the same85 alias (`--port` on root AND on `serve`; parses fine, help renders both), but name-based86 `GetResult("--port")` resolves the ROOT option (DFS order) when both are given — the87 subcommand's value is unreachable by name. Expose the subcommand's `Option` instances as88 `internal static readonly` fields and read `parseResult.GetValue(optionInstance)`89 (defaults via `DefaultValueFactory = _ => value` are honored: absent → default).90- **Custom value grammar: `Option.Validators` is the ONLY native parse-error hook in GA91 (no `ParseArgument`).** The validator delegate receives the `OptionResult`; read the raw92 token with `result.GetValueOrDefault<string>()` (null when absent) and report failure via93 `result.AddError("...")` — there is NO `ErrorMessage` property in GA (CS1061). Pattern:94 `Option<string>` + a pure static `TryParse` + validator, e.g. SCL's built-in TimeSpan95 converter exists but lacks `4h` sugar, so `--idle-timeout` stays a string option parsed96 by a custom span parser. Pin the invalid-value case with a parse test (`Errors` not empty).97- **Pure span parsers must be total:** `TimeSpan.FromDays(int)` throws98 `ArgumentOutOfRangeException` (NOT `OverflowException`) on overflow — a `TryParse` that99 wraps `From*` must catch it (or bounds-check) or `"999999999d"` throws instead of100 returning false. Add an overflow row to the invalid-input theory.101- `Option<T>` ctor is `(string name, string[] aliases)` — no description parameter (set via102 property); help placeholder via `HelpName` (gives `--transport <stdio|http|https>`);103 hide via `Symbol.Hidden`; `RootCommand.ExecutableName` is a read-only STATIC property.104- GA help format is "Description:" / "Usage:" / "Options:" (NOT "USAGE:"), built-in help105 aliases `-?, -h, --help`; `--version` prints the ENTRY assembly's informational version —106 in a test host that is the test runner, so assert routing only and prove the real version107 via the packed-tool smoke gate.108109### Re-verifying the pins when the package version changes110111The pins above are for 2.0.10. When the package bumps, re-pin EMPIRICALLY instead of112trusting this doc: build a throwaway console project (restore hits the NuGet cache, so it's113fast) that parses a battery of arg sets and prints `parseResult.Action?.GetType().FullName`,114error count, showHelp/showVersion, and the `Invoke` exit code. Starter:115`templates/system-commandline-idiom-probe/` (copy into /tmp — never into the repo under116review). Probe pitfalls that cost real time:117118- Fastest existence check, no decompiler: grep the shipped package XML docs —119 `grep -o 'member name="[MTPS]:System.CommandLine.Parsing.ParseResult.[^"]*"' ~/.nuget/packages/system.commandline/<ver>/lib/net8.0/System.CommandLine.xml`120 (swap the type for any API surface; `grep -A8 'M:System.CommandLine.Parsing.SymbolResult.AddError'`121 for the doc text). Member existence — and the exact GA name — resolved in seconds.122- `Assembly.GetType("System.CommandLine.VersionOptionAction")` returns **null** — the type123 is NESTED (`System.CommandLine.VersionOption+VersionOptionAction`, note the `+`). The124 instance's `GetType().Name` is still just "VersionOptionAction", which is why the name125 pin works.126- `someType is HelpAction` where `someType` is a `Type` object is ALWAYS false (CS0184127 warning) — test the ACTION INSTANCE (`parseResult.Action is HelpAction`), never128 `parseResult.Action?.GetType() is ...`.129- A bare SDK-style csproj has no implicit usings — `Console`/`TextWriter` fail to resolve130 until you add `<ImplicitUsings>enable</ImplicitUsings>`.131- After the bump, re-run the E2E suite too: `--help --bogus` (help-wins), duplicate-option132 (error, not last-wins), and the WebApplicationFactory hidden-flag triplet are the three133 behaviors most likely to shift between versions.134135### WebApplicationFactory trap (parse-first entry points)136137`WebApplicationFactory<T>` invokes the REAL entry point with138`--environment=Development --contentRoot=<projdir> --applicationName=<AssemblyName>`.139Parse-first code rejects these as unknown options → early return → "The entry point exited140without ever building an IHost." → every E2E test fails (the old `CreateBuilder(args)` path141silently stored them as config keys, which is why this only bites after switching to142parse-first + `CreateBuilder([])`). Fix: declare the three as HIDDEN no-op options143(`new Option<string>("--environment") { Hidden = true }`, likewise `--contentRoot`,144`--applicationName`), values intentionally never consumed, and pin with a test that the145triplet parses with zero errors and yields no options.146147**UseSetting IS an arg-injection path (verified 2026-08-04 with a throwaway probe).**148`builder.UseSetting("key", "value")` inside `ConfigureWebHost` is rendered as a149`--key=value` argument to the REAL entry point's Program.Main (WebApplicationFactory's150`DeferredHostBuilder` flattens its host configuration into args; captured verbatim:151`--transport=http --environment=Development --dataRoot=/tmp/xxx --contentRoot=... --applicationName=...`).152This is the escape hatch when a refactor removes env vars: E2E factories can still153inject CLI-only launch flags (`UseSetting("data-root", tempRoot)` → `--data-root=<temp>`).154The earlier claim "WebApplicationFactory can't inject args anyway" is WRONG — the155probe recipe (a throwaway web `Probe` project + `Probe.Tests` capturing `Program.Main`'s156args into a static, `dotnet test` once) is how to re-verify when the SDK version changes.157158### Optional subcommands: a root with children REQUIRES a verb (verified 2026-08-04)159160A `RootCommand` (or any `Command`) that has subcommands cannot be invoked bare: parsing161`--flag value` with no verb yields the parse error "Required command was not provided."162— there is NO switch making subcommands optional in 2.0.10 GA. The163"bare invocation runs the daemon, verbs run commands" pattern: build TWO roots over the164same launch options — the full tree (flags + verbs) and a launch-only root (flags only).165Parse the full tree first (help/version render from it, so `--help` lists the verbs);166when it has errors AND a token pre-scan (skip `--opt` + its value / `--opt=value`,167then look for known verb tokens) finds no verb, re-parse against the launch-only root168(genuine option errors re-surface there). Pin with tests: bare flags parse with zero169errors; a verb without its subcommand still errors.170171## Precedence and wiring172173- **CLI > env > default** is the ASP.NET Core default layering (AspNetCore.Docs: command174 line, env vars, user secrets (dev), appsettings.{ENV}.json, appsettings.json).175- If you parse args yourself, pass `[]` to `WebApplication.CreateBuilder([])` so the176 built-in CommandLine config provider doesn't re-parse consumed flags into177 `IConfiguration` (and doesn't silently accept typos as config keys).178- Implement the merge as one pure class: `cliValue ?? envValue ?? default`, keeping env a179 first-class middle layer. When a single-channel ruling removes env entirely, keep the180 same pure-class shape minus `readEnv`. Bonus: env-mutating E2E factories181 (WebApplicationFactory) keep working without migration — and when env is gone,182 `builder.UseSetting(...)` in `ConfigureWebHost` still injects launch flags as183 `--key=value` entry args (see the WebApplicationFactory trap section above).184- `~` expansion: only path options, done in the merge layer.185186187> Config-verb trees: read `references/config-verb-trees.md` when building one-shot config-verb commands over the bank.188189## Secrets policy190191- Never declare secrets as CLI options: args are visible in process listings and client192 config files (`.mcp.json` may be tracked/shared). Undeclared options fail with the193 parser's unknown-option error — that error IS the defense.194- Test it: parser rejects `--<secret-flag>` as unknown.195- Owner rulings can override this (2026-08-04: secrets moved into the settings196 table — DB encrypted at rest). FINAL state (2026-08-05): `model set openai --api-key`197 remains a documented option on that one command; `sync add s3` credentials are198 INTERACTIVE — the `--access-key/--secret-key` options were deleted. Interactive-entry199 pattern that worked: `RunAsync(commandPath, parseResult, store, stdout, stderr, stdin,200 ct)` gains a `TextReader stdin` (Program.cs passes `Console.In`; tests pass201 `StringReader`/`TextReader.Null` — every call site ripples: BDD steps, E2E host, unit202 helpers). The command prompts on STDERR (`S3 access key (empty aborts): `), reads one203 line, trims, and on empty input writes `<tool>: <name> key required — …` to stderr,204 returns exit 1, and persists NOTHING (prompt before any settings write). Pin with tests:205 happy path via StringReader; empty stdin → exit 1 + zero settings rows. Prefer206 interactive entry over secret flags when a ruling demands secrets-in-settings; a kept207 flag goes on the specific config command only (never the root), stays out of tracked208 example files, and the argv-visibility tradeoff is flagged in the report.209210## Client config conventions (MCP global tools)211212- `.mcp.json`: `{"mcpServers":{"<name>":{"command":"<tool-name>","args":[...],"env":{...}}}}` —213 dotnet tools appear as bare command names (e.g. `"command": "the project"`), unlike214 npx/dotnet-run forms.215- Zero-config entry works when defaults are: stdio transport, user-scoped data dir,216 rw access mode.217- MCP registry manifest (`server.json`, schema 2025-10-17): `packageArguments` (named218 `--flag={value}` or positional, `{var}` substitution) is a separate channel from219 `environmentVariables` — keep args static/`[]` and secrets in environmentVariables.220221## Research technique: primary-source facts via curl222223When web_extract is unavailable (search-only backend), gather package/repo facts directly —224versions via the NuGet flatcontainer API, metadata via the catalog, repo status via the225GitHub API, behavior from raw source. Exact recipe + verified 2026-08 facts with citations:226Read `references/nuget-evidence-research.md` when researching NuGet package facts.227228## Gotchas229230- In stdio-MCP tools, help/version must render to stderr — stdout is the protocol channel.231- A root with children REQUIRES a verb — an optional-subcommand root silently rejects bare invocations.232## References233234- `references/system-commandline-2.0.10-ga-api.md` — System.CommandLine 2.0.10 GA API surface notes; read when the 2.0.10 API surface is in question.235- `references/system-commandline-2.0.10-option-validation.md` — Option.Validators and validation ordering; read when Option.Validators ordering matters.236- `references/system-commandline-2.0.10-subcommand-options.md` — subcommand option scoping and parse results; read when scoping subcommand options.237- `references/system-commandline-bool-flags-and-prescan.md` — bool-flag parsing traps and the pre-scan pattern for help/version detection; read when writing bool-flag pre-scans.238- `references/nuget-evidence-research.md` — NuGet package/API facts without docs: flatcontainer, catalog, raw source; read when researching NuGet facts without docs.