Skip to content

Commit d4f0398

Browse files
authored
Merge pull request #77 from autocarl/agent/issue-76-hidden-options
feat: add fluent hidden options
2 parents d912bcb + e95b512 commit d4f0398

68 files changed

Lines changed: 6471 additions & 379 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ jobs:
3333
strategy:
3434
fail-fast: false
3535
matrix:
36-
os: [ubuntu-latest, macos-latest]
36+
# Windows is a first-class target for a CLI/REPL framework, and its absence let a
37+
# CRLF-only test failure ship green (see the TrimEntries fix in Given_Completions).
38+
os: [ubuntu-latest, macos-latest, windows-latest]
3739
steps:
3840
- name: Checkout
3941
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -194,7 +196,15 @@ jobs:
194196
--coverage-output-format cobertura
195197
196198
- name: Test report
197-
if: always()
199+
# Creating a check run needs `checks: write`, but GitHub caps GITHUB_TOKEN
200+
# to read-only for `pull_request` events raised from a fork — the
201+
# workflow-level `permissions:` block cannot lift that ceiling. The action
202+
# then fails with "Resource not accessible by integration", which cascades
203+
# into skipping Pack and the package validation steps below. Fork PRs still
204+
# gate on the Test step and publish the .trx via the test-results artifact.
205+
# The `github.event_name` clause keeps the report alive on push builds,
206+
# where `github.event.pull_request` is null.
207+
if: always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
198208
uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1
199209
with:
200210
name: Test Results

CHANGELOG.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Changelog
2+
3+
Notable consumer-facing changes to the Repl packages. Versions are assigned automatically by
4+
Nerdbank.GitVersioning at pack time; this file groups changes by theme instead of by release.
5+
6+
## Unreleased
7+
8+
### Added — option visibility
9+
10+
- `.Hidden(bool isHidden = true)` on the option builder (`WithOption(name, option => option.Hidden())`)
11+
hides an option's canonical token, aliases, description, default, and value candidates from help,
12+
generated documentation, interactive/shell completion, and MCP tool schemas. The option remains a
13+
fully parsable, invocable part of the command line — hiding is a discovery filter, not access
14+
control. Available for direct command-handler parameters, options-group properties, manually
15+
registered global options (`ParsingOptions.GlobalOption(name).Hidden()`), and typed global options.
16+
- `.HiddenAlias(alias, isHidden = true)` and `[ReplOption(HiddenAliases = [...])]` mark specific
17+
legacy/deprecated token spellings as parser-only: the canonical token and any current aliases stay
18+
discoverable, while the hidden alias keeps binding from the CLI/REPL for backward compatibility.
19+
- `doc export` (and `docs <command path>`) reports `isHidden` / `isAutomationHidden` per option so an
20+
app author can inventory what a given command hides. Aggregate documentation (no target path) and
21+
MCP's `tools/list` always omit hidden options entirely — see `docs/commands.md` for the full
22+
visibility matrix.
23+
- A hidden option must remain omittable for every provider that can build a discovery surface.
24+
Hiding a required options-group property fails immediately at `Map` time. Hiding a required direct
25+
handler parameter defers that check to the first time discovery runs against a real service
26+
provider (aggregate documentation build or MCP startup), since a DI/synthesized-progress fallback
27+
is only knowable once one exists — see the "Provider-aware requiredness" section of
28+
`docs/commands.md`.
29+
30+
### Changed — breaking
31+
32+
- `WithOption(name, configure)` is now the only fluent entry point for configuring an existing
33+
option's metadata (visibility included). This lands within the same change that introduces it —
34+
no previously published `Option(...)` API is removed by this release.
35+
36+
### Compatibility notes
37+
38+
- `doc export --json` (and other structured documentation exports) now unconditionally include the
39+
`isHidden` and `isAutomationHidden` fields on every option. A consumer validating that output
40+
against a closed schema (`additionalProperties: false`) will need to allow these two additive
41+
fields.
42+
- The historical six-parameter `ParsingOptions.AddGlobalOptionCore` descriptor is preserved as a
43+
distinct overload (not folded into a defaulted parameter) so an already-compiled `Repl.Defaults`
44+
binary continues to work against a newer `Repl.Core`. The reverse is not guaranteed: this release's
45+
`Repl.Defaults` calls APIs that only exist in this release's `Repl.Core`, so upgrading only one of
46+
the two packages independently is not supported — upgrade them together.

docs/commands.md

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,121 @@ app.Map(
5858

5959
Root help now includes a dedicated `Global Options:` section with built-ins plus custom options registered through `options.Parsing.AddGlobalOption<T>(...)`.
6060

61+
### Hiding individual options
62+
63+
Use `.WithOption(<targetName>, option => option.Hidden())` to hide one command option without hiding its command. The target is the CLR handler parameter or options-group property name, not the rendered `--option-name` token:
64+
65+
```csharp
66+
app.Map(
67+
"deploy",
68+
([ReplOption(Name = "internal-mode")] bool internalMode = false) => internalMode)
69+
.WithOption("internalMode", option => option.Hidden());
70+
```
71+
72+
The callback shape is deliberate. `CommandBuilder` and `OptionBuilder` both expose `Hidden` and `AutomationHidden`, so an API returning the option builder would let `.Option("x").Hidden()` sit in a chain reading exactly like the command-level `.Hidden()` while meaning something quite different, with nothing to signal that the subject had changed. `WithOption` keeps the subject explicit and the chain on the command.
73+
74+
For declarative registration, set `Hidden = true` on `ReplOptionAttribute`. This works on direct parameters, options-group properties, and typed global-options properties:
75+
76+
```csharp
77+
public sealed class DeploymentOptions
78+
{
79+
[ReplOption(Hidden = true)]
80+
public string? InternalToken { get; set; }
81+
}
82+
```
83+
84+
Manually registered global options can be selected after registration:
85+
86+
```csharp
87+
app.Options(options =>
88+
{
89+
options.Parsing.AddGlobalOption<string>("internal-tenant");
90+
options.Parsing.GlobalOption("internal-tenant").Hidden();
91+
});
92+
```
93+
94+
#### Keeping a legacy alias callable but hidden
95+
96+
Do not hide the whole option when only an old spelling is deprecated. Declare the legacy token in `HiddenAliases`; it remains accepted by CLI and REPL parsing while the canonical token stays visible:
97+
98+
```csharp
99+
app.Map(
100+
"deploy",
101+
([ReplOption(Name = "tenant", HiddenAliases = ["--account", "-a"])] string? tenant = null) => tenant);
102+
```
103+
104+
`HiddenAliases` registers those tokens itself; they do not also need to appear in `Aliases`. If the same exact token appears in both arrays, hidden visibility wins. Tokens remain case-sensitive when the option is case-sensitive, so `--account` can be hidden without hiding a distinct `--ACCOUNT` alias.
105+
106+
The fluent form operates on an alias already declared in `Aliases`:
107+
108+
```csharp
109+
app.Map(
110+
"deploy",
111+
([ReplOption(Name = "tenant", Aliases = ["--account"])] string? tenant = null) => tenant)
112+
.WithOption("tenant", option => option.HiddenAlias("--account"));
113+
```
114+
115+
The same contract applies to global options:
116+
117+
```csharp
118+
app.Options(options =>
119+
{
120+
options.Parsing.AddGlobalOption<string>("tenant", aliases: ["--account"]);
121+
options.Parsing.GlobalOption("tenant").HiddenAlias("--account");
122+
});
123+
124+
public sealed class GlobalOptions
125+
{
126+
[ReplOption(HiddenAliases = ["--account"])]
127+
public string? Tenant { get; set; }
128+
}
129+
```
130+
131+
A hidden alias is omitted from command/root help, typo suggestions, interactive and shell completion (including value completion after manually typing it), aggregate and exact-path documentation, and MCP schemas. MCP callers use the visible canonical argument name; the hidden alias is only a backwards-compatible CLI/REPL fallback. Like every hidden surface, this is discoverability metadata rather than authorization.
132+
133+
Hidden options are omitted from help, interactive and shell completion (including value providers), documentation export, and generated MCP schemas. They remain valid parser inputs on the command line and in the REPL, and bind normally when supplied explicitly.
134+
135+
Over MCP the omission is stricter. The advertised tool schema and the list of accepted arguments are built from the same option list, so a `tools/call` that supplies a hidden option is **rejected** — exactly like a call to a hidden command. A hidden option is therefore reachable from a human-driven CLI or REPL session, but not from an agent.
136+
137+
A hidden option must be omittable for the provider that builds a discovery surface, because that client otherwise has no valid invocation path. Repl checks the same fallbacks and precedence as the handler binder: a CLR default or nullable shape, synthetic `IProgress<double>`/`IProgress<ReplProgressEvent>` from an available `IReplInteractionChannel`, then direct `IServiceProvider.GetService` before explicit `ExactlyOne`/`OneOrMore` lower bounds are enforced. Direct handler parameters are validated when aggregate documentation or MCP discovery is built, not by `Map` or `.Hidden()`, because `Run` and MCP may receive an external provider only after mapping. If the active provider cannot supply the value, discovery fails with the required-hidden diagnostic rather than advertising an impossible command. Required options-group properties still fail immediately during mapping because that binding path never consults DI.
138+
139+
A fluent `.Hidden(isHidden: false)` overrides `ReplOptionAttribute.Hidden`. For direct handler parameters this can restore visibility before provider-aware discovery. A required hidden options-group property is rejected while the command is mapped, before a fluent override can run, so fix that attribute instead.
140+
141+
> **Hiding is not access control.** A hidden option stays a fully invocable part of the command line for anyone who knows its name — nothing about it is authenticated, authorized, or secret. Use it for deprecated switches, diagnostic escape hatches and migration aliases. Gate privileged behavior with real authorization, never with obscurity.
142+
143+
### Hiding an option from agents only
144+
145+
`.AutomationHidden()` is the option-level counterpart of `CommandBuilder.AutomationHidden()`: it withholds the option from programmatic surfaces while leaving it fully visible to people.
146+
147+
```csharp
148+
app.Map("deploy", Deploy)
149+
.WithOption("traceId", option => option.AutomationHidden());
150+
```
151+
152+
The declarative form is `[ReplOption(AutomationHidden = true)]`. It is **not** supported on typed global-options properties and fails fast there, because global options never reach a programmatic surface at all — the flag would have nothing to act on. `GlobalOptionBuilder` has no `AutomationHidden` for the same reason, enforced by its type rather than by a runtime check.
153+
154+
The two axes are independent:
155+
156+
| Surface | `.Hidden()` | `.AutomationHidden()` |
157+
|---|---|---|
158+
| Command help | omitted | **listed** |
159+
| Interactive and shell completion | omitted | **offered** |
160+
| Aggregate `doc export` | omitted | **exported, flagged** |
161+
| `doc export <command>` (exact path) | **exported, flagged** | **exported, flagged** |
162+
| MCP tool schema and prompt arguments | omitted | omitted |
163+
| MCP `tools/call` | rejected | rejected |
164+
| CLI and REPL parsing and binding | unaffected | unaffected |
165+
166+
Both are discovery filters, and neither is an access-control boundary.
167+
168+
### Troubleshooting an option that will not bind
169+
170+
Almost always a target-name mistake. `WithOption` takes the **CLR** handler parameter or options-group property name, not the rendered `--option-name` token — `WithOption("internalMode", …)`, never `WithOption("internal-mode", …)`. An unknown target throws at configuration time and the message lists the targets that do exist, so read it rather than guessing.
171+
172+
If the option is genuinely hidden and you want to confirm what the app thinks, export the command explicitly: `doc export <command path> --json` includes hidden options with `"isHidden": true`. The aggregate export omits them, so target the command.
173+
174+
Note there is no built-in signal for *use* of a hidden option. If the point is retiring a deprecated switch, record that in the handler yourself — otherwise nothing will tell you when it has become safe to remove.
175+
61176
### Accessing global options outside handlers
62177

63178
Parsed global option values are available via `IGlobalOptionsAccessor`, registered in DI automatically. This enables access from middleware, DI service factories, and handlers:

docs/for-coding-agents.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ Use these annotations to help agents make safer decisions:
144144
| `.OpenWorld()` | Talks to external systems; expect latency and failures. |
145145
| `.LongRunning()` | May take time; use call-now / poll-later patterns. |
146146
| `.AutomationHidden()` | Do not expose this command to MCP automation. |
147+
| `.WithOption(name, o => o.AutomationHidden())` | Keep this one option out of the tool schema; the command stays visible. |
147148

148149
Unannotated tools force agents to assume the worst. Annotate every command that will be visible through MCP.
149150

docs/mcp-overview.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Commands map to MCP primitives automatically:
4545
| `.AsPrompt()` | Prompt | Reusable instruction template |
4646
| `.AsMcpAppResource()` | Tool + `ui://` HTML resource | Interactive UI for capable hosts |
4747
| `.AutomationHidden()` | _(nothing)_ | Excluded from MCP entirely |
48+
| `.WithOption(name, o => o.AutomationHidden())` | Tool without that option | Option people may use but agents should not |
4849

4950
## Annotations
5051

docs/mcp-reference.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,8 @@ Notes:
301301
|---|---|---|
302302
| `.AutomationHidden()` | Per-command | Interactive-only commands |
303303
| `.Hidden()` | Per-command | Hidden from all surfaces |
304+
| `.WithOption(name, o => o.AutomationHidden())` | Per-option | Option people may use but agents should not |
305+
| `.WithOption(name, o => o.Hidden())` | Per-option | Deprecated or diagnostic switches |
304306
| `CommandFilter` | App-level | `o.CommandFilter = c => !c.Path.StartsWith("admin")` |
305307
| Module presence + `Programmatic` | Per-module | Entire feature areas |
306308

docs/parameter-system.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Application-facing parameter DSL:
2626
- explicit `Aliases` (full tokens, for example `-m`, `--mode`)
2727
- explicit `ReverseAliases` (for example `--no-verbose`)
2828
- `Mode` (`OptionOnly`, `ArgumentOnly`, `OptionAndPositional`)
29+
- `Hidden` to suppress discovery without changing parsing or binding
2930
- optional per-parameter `CaseSensitivity`
3031
- optional `Arity`
3132
- `ReplArgumentAttribute`
@@ -41,6 +42,8 @@ Supporting enums:
4142
- `ReplParameterMode`
4243
- `ReplArity`
4344

45+
Option visibility can also be configured fluently with `CommandBuilder.WithOption(targetName, option => option.Hidden())` and `ParsingOptions.GlobalOption(name).Hidden()`. Fluent visibility overrides the attribute value, including `.Hidden(isHidden: false)`. Unknown targets fail during configuration instead of being ignored. Hidden options must be omittable, but inferred `ExactlyOne` alone does not make an omittable nullable/defaulted CLR parameter invalid. Required options-group properties fail immediately during mapping because their binder never consults DI. Required direct handler parameters are validated later, when aggregate documentation or MCP discovery knows the active provider; discovery accepts them when the binder can synthesize progress from `IReplInteractionChannel` or resolve the parameter from DI, and otherwise emits the required-hidden diagnostic.
46+
4447
### Options groups
4548

4649
- `ReplOptionsGroupAttribute` (on a class) marks it as a reusable parameter group
@@ -107,6 +110,9 @@ This same schema drives:
107110
- command help option sections
108111
- shell option completion candidates
109112
- exported documentation option metadata
113+
- generated MCP tool and prompt schemas
114+
115+
Hidden options are filtered from those discovery surfaces, while the same schema continues to accept and bind their tokens during direct execution.
110116

111117
## System.CommandLine comparison
112118

src/Repl.Core/Autocomplete/AutocompleteEngine.cs

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -620,12 +620,13 @@ private ConsoleLineReader.AutocompleteSuggestion[] AppendInvalidAlternativeIfNee
620620
return [];
621621
}
622622

623-
var comparison = ResolveOptionStringComparison();
623+
var globalConfiguration = app.OptionsSnapshot.Parsing.CaptureGlobalOptionConfiguration();
624+
var comparison = globalConfiguration.CaseSensitivity.ToStringComparison();
624625
var comparer = StringComparer.FromComparison(comparison);
625626
var tokens = new List<string>();
626627
var dedupe = new HashSet<string>(comparer);
627-
OptionTokenCompletionSource.CollectGlobalOptionTokens(
628-
app.OptionsSnapshot, currentTokenPrefix, comparison, dedupe, tokens);
628+
var customGlobalOwnership = OptionTokenCompletionSource.CollectGlobalOptionTokens(
629+
app.OptionsSnapshot, globalConfiguration, currentTokenPrefix, comparison, dedupe, tokens);
629630

630631
// Source route options from the single route this prefix resolves to (already
631632
// computed for the whole pass), and only when EVERY positional segment — required or
@@ -636,9 +637,10 @@ private ConsoleLineReader.AutocompleteSuggestion[] AppendInvalidAlternativeIfNee
636637
&& commandPrefix.Length == match.Route.Template.Segments.Count)
637638
{
638639
OptionTokenCompletionSource.CollectRouteOptionTokens(
639-
match.Route,
640+
match.Route.OptionSchema,
641+
customGlobalOwnership,
640642
currentTokenPrefix,
641-
app.OptionsSnapshot.Parsing.OptionCaseSensitivity,
643+
globalConfiguration.CaseSensitivity,
642644
dedupe,
643645
tokens);
644646
}
@@ -1588,10 +1590,18 @@ internal static bool IsControlFreeValue(string value) =>
15881590
return [];
15891591
}
15901592

1591-
var entries = match.Route.OptionSchema.ResolveToken(
1592-
pendingOptionToken, app.OptionsSnapshot.Parsing.OptionCaseSensitivity);
1593+
var caseSensitivity = app.OptionsSnapshot.Parsing.OptionCaseSensitivity;
1594+
var entries = match.Route.OptionSchema.ResolveToken(pendingOptionToken, caseSensitivity);
15931595
foreach (var entry in entries)
15941596
{
1597+
if (!match.Route.OptionSchema.IsEntryDiscoverableForTypedToken(
1598+
entry,
1599+
pendingOptionToken,
1600+
caseSensitivity))
1601+
{
1602+
continue;
1603+
}
1604+
15951605
// Same keystroke rule as the positional path: providers only run for an explicit
15961606
// completion request; live-hint refreshes fall through to the static enum fallback.
15971607
if (providersAllowed

0 commit comments

Comments
 (0)