Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
# Windows is a first-class target for a CLI/REPL framework, and its absence let a
# CRLF-only test failure ship green (see the TrimEntries fix in Given_Completions).
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
Expand Down Expand Up @@ -194,7 +196,15 @@ jobs:
--coverage-output-format cobertura

- name: Test report
if: always()
# Creating a check run needs `checks: write`, but GitHub caps GITHUB_TOKEN
# to read-only for `pull_request` events raised from a fork — the
# workflow-level `permissions:` block cannot lift that ceiling. The action
# then fails with "Resource not accessible by integration", which cascades
# into skipping Pack and the package validation steps below. Fork PRs still
# gate on the Test step and publish the .trx via the test-results artifact.
# The `github.event_name` clause keeps the report alive on push builds,
# where `github.event.pull_request` is null.
if: always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1
with:
name: Test Results
Expand Down
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Changelog

Notable consumer-facing changes to the Repl packages. Versions are assigned automatically by
Nerdbank.GitVersioning at pack time; this file groups changes by theme instead of by release.

## Unreleased

### Added — option visibility

- `.Hidden(bool isHidden = true)` on the option builder (`WithOption(name, option => option.Hidden())`)
hides an option's canonical token, aliases, description, default, and value candidates from help,
generated documentation, interactive/shell completion, and MCP tool schemas. The option remains a
fully parsable, invocable part of the command line — hiding is a discovery filter, not access
control. Available for direct command-handler parameters, options-group properties, manually
registered global options (`ParsingOptions.GlobalOption(name).Hidden()`), and typed global options.
- `.HiddenAlias(alias, isHidden = true)` and `[ReplOption(HiddenAliases = [...])]` mark specific
legacy/deprecated token spellings as parser-only: the canonical token and any current aliases stay
discoverable, while the hidden alias keeps binding from the CLI/REPL for backward compatibility.
- `doc export` (and `docs <command path>`) reports `isHidden` / `isAutomationHidden` per option so an
app author can inventory what a given command hides. Aggregate documentation (no target path) and
MCP's `tools/list` always omit hidden options entirely — see `docs/commands.md` for the full
visibility matrix.
- A hidden option must remain omittable for every provider that can build a discovery surface.
Hiding a required options-group property fails immediately at `Map` time. Hiding a required direct
handler parameter defers that check to the first time discovery runs against a real service
provider (aggregate documentation build or MCP startup), since a DI/synthesized-progress fallback
is only knowable once one exists — see the "Provider-aware requiredness" section of
`docs/commands.md`.

### Changed — breaking

- `WithOption(name, configure)` is now the only fluent entry point for configuring an existing
option's metadata (visibility included). This lands within the same change that introduces it —
no previously published `Option(...)` API is removed by this release.

### Compatibility notes

- `doc export --json` (and other structured documentation exports) now unconditionally include the
`isHidden` and `isAutomationHidden` fields on every option. A consumer validating that output
against a closed schema (`additionalProperties: false`) will need to allow these two additive
fields.
- The historical six-parameter `ParsingOptions.AddGlobalOptionCore` descriptor is preserved as a
distinct overload (not folded into a defaulted parameter) so an already-compiled `Repl.Defaults`
binary continues to work against a newer `Repl.Core`. The reverse is not guaranteed: this release's
`Repl.Defaults` calls APIs that only exist in this release's `Repl.Core`, so upgrading only one of
the two packages independently is not supported — upgrade them together.
115 changes: 115 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,121 @@ app.Map(

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

### Hiding individual options

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:

```csharp
app.Map(
"deploy",
([ReplOption(Name = "internal-mode")] bool internalMode = false) => internalMode)
.WithOption("internalMode", option => option.Hidden());
```

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.

For declarative registration, set `Hidden = true` on `ReplOptionAttribute`. This works on direct parameters, options-group properties, and typed global-options properties:

```csharp
public sealed class DeploymentOptions
{
[ReplOption(Hidden = true)]
public string? InternalToken { get; set; }
}
```

Manually registered global options can be selected after registration:

```csharp
app.Options(options =>
{
options.Parsing.AddGlobalOption<string>("internal-tenant");
options.Parsing.GlobalOption("internal-tenant").Hidden();
});
```

#### Keeping a legacy alias callable but hidden

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:

```csharp
app.Map(
"deploy",
([ReplOption(Name = "tenant", HiddenAliases = ["--account", "-a"])] string? tenant = null) => tenant);
```

`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.

The fluent form operates on an alias already declared in `Aliases`:

```csharp
app.Map(
"deploy",
([ReplOption(Name = "tenant", Aliases = ["--account"])] string? tenant = null) => tenant)
.WithOption("tenant", option => option.HiddenAlias("--account"));
```

The same contract applies to global options:

```csharp
app.Options(options =>
{
options.Parsing.AddGlobalOption<string>("tenant", aliases: ["--account"]);
options.Parsing.GlobalOption("tenant").HiddenAlias("--account");
});

public sealed class GlobalOptions
{
[ReplOption(HiddenAliases = ["--account"])]
public string? Tenant { get; set; }
}
```

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.

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.

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.

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.

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.

> **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.

### Hiding an option from agents only

`.AutomationHidden()` is the option-level counterpart of `CommandBuilder.AutomationHidden()`: it withholds the option from programmatic surfaces while leaving it fully visible to people.

```csharp
app.Map("deploy", Deploy)
.WithOption("traceId", option => option.AutomationHidden());
```

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.

The two axes are independent:

| Surface | `.Hidden()` | `.AutomationHidden()` |
|---|---|---|
| Command help | omitted | **listed** |
| Interactive and shell completion | omitted | **offered** |
| Aggregate `doc export` | omitted | **exported, flagged** |
| `doc export <command>` (exact path) | **exported, flagged** | **exported, flagged** |
| MCP tool schema and prompt arguments | omitted | omitted |
| MCP `tools/call` | rejected | rejected |
| CLI and REPL parsing and binding | unaffected | unaffected |

Both are discovery filters, and neither is an access-control boundary.

### Troubleshooting an option that will not bind

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.
Comment thread
carldebilly marked this conversation as resolved.

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.

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.

### Accessing global options outside handlers

Parsed global option values are available via `IGlobalOptionsAccessor`, registered in DI automatically. This enables access from middleware, DI service factories, and handlers:
Expand Down
1 change: 1 addition & 0 deletions docs/for-coding-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ Use these annotations to help agents make safer decisions:
| `.OpenWorld()` | Talks to external systems; expect latency and failures. |
| `.LongRunning()` | May take time; use call-now / poll-later patterns. |
| `.AutomationHidden()` | Do not expose this command to MCP automation. |
| `.WithOption(name, o => o.AutomationHidden())` | Keep this one option out of the tool schema; the command stays visible. |

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

Expand Down
1 change: 1 addition & 0 deletions docs/mcp-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ Commands map to MCP primitives automatically:
| `.AsPrompt()` | Prompt | Reusable instruction template |
| `.AsMcpAppResource()` | Tool + `ui://` HTML resource | Interactive UI for capable hosts |
| `.AutomationHidden()` | _(nothing)_ | Excluded from MCP entirely |
| `.WithOption(name, o => o.AutomationHidden())` | Tool without that option | Option people may use but agents should not |

## Annotations

Expand Down
2 changes: 2 additions & 0 deletions docs/mcp-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,8 @@ Notes:
|---|---|---|
| `.AutomationHidden()` | Per-command | Interactive-only commands |
| `.Hidden()` | Per-command | Hidden from all surfaces |
| `.WithOption(name, o => o.AutomationHidden())` | Per-option | Option people may use but agents should not |
| `.WithOption(name, o => o.Hidden())` | Per-option | Deprecated or diagnostic switches |
| `CommandFilter` | App-level | `o.CommandFilter = c => !c.Path.StartsWith("admin")` |
| Module presence + `Programmatic` | Per-module | Entire feature areas |

Expand Down
6 changes: 6 additions & 0 deletions docs/parameter-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Application-facing parameter DSL:
- explicit `Aliases` (full tokens, for example `-m`, `--mode`)
- explicit `ReverseAliases` (for example `--no-verbose`)
- `Mode` (`OptionOnly`, `ArgumentOnly`, `OptionAndPositional`)
- `Hidden` to suppress discovery without changing parsing or binding
- optional per-parameter `CaseSensitivity`
- optional `Arity`
- `ReplArgumentAttribute`
Expand All @@ -41,6 +42,8 @@ Supporting enums:
- `ReplParameterMode`
- `ReplArity`

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.

### Options groups

- `ReplOptionsGroupAttribute` (on a class) marks it as a reusable parameter group
Expand Down Expand Up @@ -107,6 +110,9 @@ This same schema drives:
- command help option sections
- shell option completion candidates
- exported documentation option metadata
- generated MCP tool and prompt schemas

Hidden options are filtered from those discovery surfaces, while the same schema continues to accept and bind their tokens during direct execution.

## System.CommandLine comparison

Expand Down
10 changes: 8 additions & 2 deletions src/Repl.Core/Autocomplete/AutocompleteEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -624,7 +624,7 @@ private ConsoleLineReader.AutocompleteSuggestion[] AppendInvalidAlternativeIfNee
var comparer = StringComparer.FromComparison(comparison);
var tokens = new List<string>();
var dedupe = new HashSet<string>(comparer);
OptionTokenCompletionSource.CollectGlobalOptionTokens(
var customGlobalOwnership = OptionTokenCompletionSource.CollectGlobalOptionTokens(
app.OptionsSnapshot, currentTokenPrefix, comparison, dedupe, tokens);

// Source route options from the single route this prefix resolves to (already
Expand All @@ -636,7 +636,8 @@ private ConsoleLineReader.AutocompleteSuggestion[] AppendInvalidAlternativeIfNee
&& commandPrefix.Length == match.Route.Template.Segments.Count)
{
OptionTokenCompletionSource.CollectRouteOptionTokens(
match.Route,
match.Route.OptionSchema,
customGlobalOwnership,
currentTokenPrefix,
app.OptionsSnapshot.Parsing.OptionCaseSensitivity,
dedupe,
Expand Down Expand Up @@ -1592,6 +1593,11 @@ internal static bool IsControlFreeValue(string value) =>
pendingOptionToken, app.OptionsSnapshot.Parsing.OptionCaseSensitivity);
foreach (var entry in entries)
{
if (!match.Route.OptionSchema.IsEntryDiscoverable(entry))
{
continue;
}

// Same keystroke rule as the positional path: providers only run for an explicit
// completion request; live-hint refreshes fall through to the static enum fallback.
if (providersAllowed
Expand Down
Loading
Loading