From 12f2c3749b3a00ac55d8395578908f9a0c3f77ae Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 28 Feb 2026 15:02:05 -0500 Subject: [PATCH 1/7] Add hosted-safe protocol passthrough with injectable IO context --- README.md | 22 +++ docs/commands.md | 97 +++++++++++ docs/shell-completion.md | 1 + src/Repl.Core/CommandBuilder.cs | 17 ++ src/Repl.Core/CoreReplApp.Documentation.cs | 1 + src/Repl.Core/CoreReplApp.Routing.cs | 5 + src/Repl.Core/CoreReplApp.cs | 112 +++++++++++-- src/Repl.Core/IReplIoContext.cs | 32 ++++ src/Repl.Core/LiveReplIoContext.cs | 14 ++ .../ShellCompletion/ShellCompletionModule.cs | 1 + src/Repl.Defaults/ReplApp.cs | 2 + .../ConsoleCaptureHelper.cs | 53 ++++++ .../Given_ProtocolPassthrough.cs | 151 ++++++++++++++++++ src/Repl.Tests/Given_CommandBuilder.cs | 51 ++++++ 14 files changed, 548 insertions(+), 11 deletions(-) create mode 100644 docs/commands.md create mode 100644 src/Repl.Core/IReplIoContext.cs create mode 100644 src/Repl.Core/LiveReplIoContext.cs create mode 100644 src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs create mode 100644 src/Repl.Tests/Given_CommandBuilder.cs diff --git a/README.md b/README.md index eb2b11d..b49744c 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,26 @@ app.Context("client", client => return app.Run(args); ``` +For stdio protocol handlers (MCP/LSP/JSON-RPC, DAP, CGI-style gateways), mark the route as protocol passthrough: + +```csharp +app.Context("mcp", mcp => +{ + mcp.Map("start", async (IMcpServer server, CancellationToken ct) => + { + var exitCode = await server.RunAsync(ct); + return Results.Exit(exitCode); + }) + .AsProtocolPassthrough(); +}); +``` + +In passthrough mode, repl keeps `stdout` clean for protocol messages and sends framework diagnostics to `stderr`. +This is a strong fit for MCP-style stdio servers where the protocol stream must stay pristine. +Typical shape is `mytool mcp start` in passthrough mode, while `mytool start` stays a normal CLI command. +It also maps well to DAP/CGI-style stdio flows; socket-first variants (for example FastCGI/FastAGI) usually do not require passthrough. +Use this mode directly in local CLI/console runs. For hosted terminal sessions (`IReplHost` / remote transports), handlers should request `IReplIoContext`; console-bound toolings that use `Console.*` directly remain CLI-only. + One-shot CLI: ```text @@ -213,6 +233,7 @@ ws-7c650a64 websocket [::1]:60288 301x31 xterm-256color 1m 34s 1s - **Output pipeline** with transformers and aliases (`--output:`, `--json`, `--yaml`, `--markdown`, …) - **Typed result model** (`Results.Ok/Error/Validation/NotFound/Cancelled`, etc.) +- **Protocol passthrough mode** for stdio transports (`AsProtocolPassthrough()`), keeping `stdout` reserved for protocol payloads - **Typed interactions**: prompts, progress, status, timeouts, cancellation - **Session model + metadata** (transport, terminal identity, window size, ANSI capabilities, etc.) - **Hosting primitives** for running sessions over streams, sockets, or custom carriers @@ -254,6 +275,7 @@ Package details: ## Getting started - Architecture blueprint: [`docs/architecture.md`](docs/architecture.md) +- Command reference: [`docs/commands.md`](docs/commands.md) - Terminal/session metadata: [`docs/terminal-metadata.md`](docs/terminal-metadata.md) - Testing toolkit: [`docs/testing-toolkit.md`](docs/testing-toolkit.md) - Conditional module presence: [`docs/module-presence.md`](docs/module-presence.md) diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..3304803 --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,97 @@ +# Command Reference + +This page documents **framework-level commands and flags** provided by Repl Toolkit. +Your application commands are defined by your own route mappings. + +## Discover commands + +- CLI help at current scope: `myapp --help` +- Help for a scoped path: `myapp contact --help` +- Interactive help: `help` or `?` + +## Global flags + +These flags are parsed before route execution: + +- `--help` +- `--interactive` +- `--no-interactive` +- `--no-logo` +- `--output:` +- output aliases mapped by `OutputOptions.Aliases` (defaults include `--json`, `--xml`, `--yaml`, `--yml`, `--markdown`) +- `--answer:[=value]` for non-interactive prompt answers + +## Ambient commands + +These commands are handled by the runtime (not by your mapped routes): + +- `help` or `?` +- `..` (interactive scope navigation; interactive mode only) +- `exit` (leave interactive session when enabled) +- `history [--limit ]` (interactive mode only) +- `complete --target [--input ]` +- `autocomplete [show]` +- `autocomplete mode ` (interactive mode only) + +Notes: + +- `history` and `autocomplete` return explicit errors outside interactive mode. +- `complete` requires a terminal route and a registered `WithCompletion(...)` provider for the selected target. + +## Shell completion management commands + +When shell completion is enabled, the `completion` context is available in CLI mode: + +- `completion install [--shell bash|powershell|zsh|fish|nu] [--force] [--silent]` +- `completion uninstall [--shell bash|powershell|zsh|fish|nu] [--silent]` +- `completion status` +- `completion detect-shell` +- `completion __complete --shell <...> --line --cursor ` (internal protocol bridge, hidden) + +See full setup and profile snippets: [shell-completion.md](shell-completion.md) + +## Optional documentation export command + +If your app calls `UseDocumentationExport()`, it adds: + +- `doc export []` + +By default this command is hidden from help/discovery unless you configure `HiddenByDefault = false`. + +## Protocol passthrough commands + +For stdio protocols (MCP/LSP/JSON-RPC), mark routes with `AsProtocolPassthrough()`. +This mode is especially well-suited for **MCP servers over stdio**, where the handler owns `stdin/stdout` end-to-end. + +Example command surface: + +```text +mytool mcp start # protocol mode over stdin/stdout +mytool start # normal CLI command +mytool status --json # normal structured output +``` + +In this model, only `mcp start` should be marked as protocol passthrough. + +Common protocol families that fit this mode: + +- MCP over stdio +- LSP / JSON-RPC over stdio +- DAP over stdio +- CGI-style process protocols (stdin/stdout contract), including AGI-style integrations + +Not typical passthrough targets: + +- socket-first variants such as FastCGI/FastAGI (their protocol stream is on TCP, not app `stdout`) + +Execution scope note: + +- protocol passthrough works out of the box for **local CLI/console execution** +- hosted terminal sessions (`IReplHost` / remote transports) require handlers to request `IReplIoContext`; console-bound toolings that use `Console.*` directly remain CLI-only + +In protocol passthrough mode: + +- global and command banners are suppressed +- repl/framework diagnostics are written to `stderr` +- `stdout` remains reserved for protocol payloads +- interactive follow-up is skipped after command execution diff --git a/docs/shell-completion.md b/docs/shell-completion.md index 01b61ea..19fdc8c 100644 --- a/docs/shell-completion.md +++ b/docs/shell-completion.md @@ -12,6 +12,7 @@ completion __complete --shell --line --cur The shell passes current line + cursor, and Repl returns candidates on `stdout` (one per line). `completion __complete` is mapped in the regular command graph through the shell-completion module (CLI channel only). +The bridge route is marked as protocol passthrough, so repl suppresses banners and routes framework diagnostics to `stderr`. The module exposes a real `completion` context scope: diff --git a/src/Repl.Core/CommandBuilder.cs b/src/Repl.Core/CommandBuilder.cs index 6e87d66..831f851 100644 --- a/src/Repl.Core/CommandBuilder.cs +++ b/src/Repl.Core/CommandBuilder.cs @@ -49,6 +49,11 @@ internal CommandBuilder(string route, Delegate handler) /// public IReadOnlyDictionary Completions => _completions; + /// + /// Gets a value indicating whether this command reserves stdin/stdout for a protocol handler. + /// + public bool IsProtocolPassthrough { get; private set; } + /// /// Gets the banner delegate rendered before command execution. /// @@ -145,4 +150,16 @@ public CommandBuilder Hidden(bool isHidden = true) IsHidden = isHidden; return this; } + + /// + /// Marks this command as protocol passthrough. + /// In this mode, repl diagnostics are routed to stderr and interactive stdin reads are skipped. + /// For hosted sessions, handlers should request to access transport streams explicitly. + /// + /// The same builder instance. + public CommandBuilder AsProtocolPassthrough() + { + IsProtocolPassthrough = true; + return this; + } } diff --git a/src/Repl.Core/CoreReplApp.Documentation.cs b/src/Repl.Core/CoreReplApp.Documentation.cs index e5663d1..74dc25e 100644 --- a/src/Repl.Core/CoreReplApp.Documentation.cs +++ b/src/Repl.Core/CoreReplApp.Documentation.cs @@ -185,6 +185,7 @@ private static bool IsFrameworkInjectedParameter(Type parameterType) => || parameterType == typeof(CoreReplApp) || parameterType == typeof(IReplSessionState) || parameterType == typeof(IReplInteractionChannel) + || parameterType == typeof(IReplIoContext) || parameterType == typeof(IReplKeyReader); private static bool IsRequiredParameter(ParameterInfo parameter) diff --git a/src/Repl.Core/CoreReplApp.Routing.cs b/src/Repl.Core/CoreReplApp.Routing.cs index 7ee8ec6..d7a6545 100644 --- a/src/Repl.Core/CoreReplApp.Routing.cs +++ b/src/Repl.Core/CoreReplApp.Routing.cs @@ -294,6 +294,11 @@ private async ValueTask TryRenderCommandBannerAsync( IServiceProvider serviceProvider, CancellationToken cancellationToken) { + if (command.IsProtocolPassthrough) + { + return; + } + if (command.Banner is { } banner && ShouldRenderBanner(outputFormat)) { await InvokeBannerAsync(banner, serviceProvider, cancellationToken).ConfigureAwait(false); diff --git a/src/Repl.Core/CoreReplApp.cs b/src/Repl.Core/CoreReplApp.cs index 42948a0..a922b8c 100644 --- a/src/Repl.Core/CoreReplApp.cs +++ b/src/Repl.Core/CoreReplApp.cs @@ -405,11 +405,13 @@ private async ValueTask ExecuteCoreAsync( { var globalOptions = GlobalOptionParser.Parse(args, _options.Output); using var runtimeStateScope = PushRuntimeState(serviceProvider, isInteractiveSession: false); - if (!_shellCompletionRuntime.IsBridgeInvocation(globalOptions.RemainingTokens)) + var prefixResolution = ResolveUniquePrefixes(globalOptions.RemainingTokens); + var resolvedGlobalOptions = globalOptions with { RemainingTokens = prefixResolution.Tokens }; + if (!ShouldSuppressGlobalBanner(resolvedGlobalOptions)) { - await TryRenderBannerAsync(globalOptions, serviceProvider, cancellationToken).ConfigureAwait(false); + await TryRenderBannerAsync(resolvedGlobalOptions, serviceProvider, cancellationToken).ConfigureAwait(false); } - var prefixResolution = ResolveUniquePrefixes(globalOptions.RemainingTokens); + if (prefixResolution.IsAmbiguous) { var ambiguous = CreateAmbiguousPrefixResult(prefixResolution); @@ -418,7 +420,6 @@ private async ValueTask ExecuteCoreAsync( return 1; } - var resolvedGlobalOptions = globalOptions with { RemainingTokens = prefixResolution.Tokens }; var preExecutionExitCode = await TryHandlePreExecutionAsync( resolvedGlobalOptions, serviceProvider, @@ -455,6 +456,22 @@ private async ValueTask ExecuteCoreAsync( } } + private bool ShouldSuppressGlobalBanner(GlobalInvocationOptions globalOptions) + { + if (_shellCompletionRuntime.IsBridgeInvocation(globalOptions.RemainingTokens)) + { + return true; + } + + if (globalOptions.HelpRequested || globalOptions.RemainingTokens.Count == 0) + { + return false; + } + + var match = Resolve(globalOptions.RemainingTokens); + return match?.Route.Command.IsProtocolPassthrough == true; + } + private async ValueTask TryHandlePreExecutionAsync( GlobalInvocationOptions options, IServiceProvider serviceProvider, @@ -489,13 +506,35 @@ private async ValueTask ExecuteMatchedCommandAndMaybeEnterInteractiveAsync( IServiceProvider serviceProvider, CancellationToken cancellationToken) { - var exitCode = await ExecuteMatchedCommandAsync( - match, - globalOptions, - serviceProvider, - scopeTokens: null, - cancellationToken) - .ConfigureAwait(false); + if (match.Route.Command.IsProtocolPassthrough + && ReplSessionIO.IsSessionActive + && !SupportsHostedProtocolPassthrough(match.Route.Command.Handler)) + { + _ = await RenderOutputAsync( + Results.Error( + "protocol_passthrough_hosted_not_supported", + $"Command '{match.Route.Template.Template}' is protocol passthrough and requires a handler parameter of type IReplIoContext in hosted sessions."), + globalOptions.OutputFormat, + cancellationToken) + .ConfigureAwait(false); + return 1; + } + + var exitCode = match.Route.Command.IsProtocolPassthrough + ? await ExecuteProtocolPassthroughCommandAsync(match, globalOptions, serviceProvider, cancellationToken) + .ConfigureAwait(false) + : await ExecuteMatchedCommandAsync( + match, + globalOptions, + serviceProvider, + scopeTokens: null, + cancellationToken) + .ConfigureAwait(false); + if (match.Route.Command.IsProtocolPassthrough) + { + return exitCode; + } + if (exitCode != 0 || !ShouldEnterInteractive(globalOptions, allowAuto: false)) { return exitCode; @@ -507,6 +546,56 @@ private async ValueTask ExecuteMatchedCommandAndMaybeEnterInteractiveAsync( return await RunInteractiveSessionAsync(interactiveScope, serviceProvider, cancellationToken).ConfigureAwait(false); } + private async ValueTask ExecuteProtocolPassthroughCommandAsync( + RouteMatch match, + GlobalInvocationOptions globalOptions, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + if (ReplSessionIO.IsSessionActive) + { + return await ExecuteMatchedCommandAsync( + match, + globalOptions, + serviceProvider, + scopeTokens: null, + cancellationToken) + .ConfigureAwait(false); + } + + using var protocolScope = ReplSessionIO.SetSession( + Console.Error, + Console.In, + ansiMode: AnsiMode.Never); + return await ExecuteMatchedCommandAsync( + match, + globalOptions, + serviceProvider, + scopeTokens: null, + cancellationToken) + .ConfigureAwait(false); + } + + private static bool SupportsHostedProtocolPassthrough(Delegate handler) + { + foreach (var parameter in handler.Method.GetParameters()) + { + if (parameter.ParameterType != typeof(IReplIoContext)) + { + continue; + } + + if (parameter.GetCustomAttributes(typeof(FromContextAttribute), inherit: true).Length > 0) + { + continue; + } + + return true; + } + + return false; + } + private async ValueTask HandleEmptyInvocationAsync( GlobalInvocationOptions globalOptions, IServiceProvider serviceProvider, @@ -1183,6 +1272,7 @@ private DefaultServiceProvider CreateDefaultServiceProvider() [typeof(IHistoryProvider)] = _options.Interactive.HistoryProvider ?? new InMemoryHistoryProvider(), [typeof(IReplKeyReader)] = new ConsoleKeyReader(), [typeof(IReplSessionInfo)] = new LiveSessionInfo(), + [typeof(IReplIoContext)] = new LiveReplIoContext(), [typeof(TimeProvider)] = TimeProvider.System, }; return new DefaultServiceProvider(defaults); diff --git a/src/Repl.Core/IReplIoContext.cs b/src/Repl.Core/IReplIoContext.cs new file mode 100644 index 0000000..fd42625 --- /dev/null +++ b/src/Repl.Core/IReplIoContext.cs @@ -0,0 +1,32 @@ +namespace Repl; + +/// +/// Exposes low-level runtime I/O streams for command handlers. +/// +public interface IReplIoContext +{ + /// + /// Gets the active input reader. + /// + TextReader Input { get; } + + /// + /// Gets the active output writer. + /// + TextWriter Output { get; } + + /// + /// Gets the active error writer. + /// + TextWriter Error { get; } + + /// + /// Gets a value indicating whether execution is currently running in a hosted session. + /// + bool IsHostedSession { get; } + + /// + /// Gets the current hosted session identifier, when available. + /// + string? SessionId { get; } +} diff --git a/src/Repl.Core/LiveReplIoContext.cs b/src/Repl.Core/LiveReplIoContext.cs new file mode 100644 index 0000000..b54a0c0 --- /dev/null +++ b/src/Repl.Core/LiveReplIoContext.cs @@ -0,0 +1,14 @@ +namespace Repl; + +internal sealed class LiveReplIoContext : IReplIoContext +{ + public TextReader Input => ReplSessionIO.Input; + + public TextWriter Output => ReplSessionIO.Output; + + public TextWriter Error => ReplSessionIO.IsSessionActive ? ReplSessionIO.Output : Console.Error; + + public bool IsHostedSession => ReplSessionIO.IsSessionActive; + + public string? SessionId => ReplSessionIO.CurrentSessionId; +} diff --git a/src/Repl.Core/ShellCompletion/ShellCompletionModule.cs b/src/Repl.Core/ShellCompletion/ShellCompletionModule.cs index 3524e59..d69607f 100644 --- a/src/Repl.Core/ShellCompletion/ShellCompletionModule.cs +++ b/src/Repl.Core/ShellCompletion/ShellCompletionModule.cs @@ -16,6 +16,7 @@ public void Map(IReplMap map) ShellCompletionConstants.ProtocolSubcommandName, (string? shell, string? line, string? cursor) => runtime.HandleBridgeRoute(shell, line, cursor)) .WithDescription("Internal completion bridge used by shell integrations.") + .AsProtocolPassthrough() .Hidden(); completion.Map( "install", diff --git a/src/Repl.Defaults/ReplApp.cs b/src/Repl.Defaults/ReplApp.cs index d8dccfb..295ad85 100644 --- a/src/Repl.Defaults/ReplApp.cs +++ b/src/Repl.Defaults/ReplApp.cs @@ -589,6 +589,7 @@ private SessionOverlayServiceProvider CreateSessionOverlay(IServiceProvider exte [typeof(IHistoryProvider)] = new InMemoryHistoryProvider(), [typeof(TimeProvider)] = TimeProvider.System, [typeof(IReplKeyReader)] = new ConsoleKeyReader(), + [typeof(IReplIoContext)] = new LiveReplIoContext(), }; var channel = new DefaultsInteractionChannel( @@ -631,6 +632,7 @@ private static void EnsureDefaultServices(IServiceCollection services, CoreReplA sp.GetService()))); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); } private sealed class ScopedReplApp(ICoreReplApp map, IServiceCollection services) : IReplApp diff --git a/src/Repl.IntegrationTests/ConsoleCaptureHelper.cs b/src/Repl.IntegrationTests/ConsoleCaptureHelper.cs index 75729ca..a9f3b35 100644 --- a/src/Repl.IntegrationTests/ConsoleCaptureHelper.cs +++ b/src/Repl.IntegrationTests/ConsoleCaptureHelper.cs @@ -21,6 +21,29 @@ public static (int ExitCode, string Text) Capture(Func action) } } + public static (int ExitCode, string StdOut, string StdErr) CaptureStdOutAndErr(Func action) + { + ArgumentNullException.ThrowIfNull(action); + + var previousOut = Console.Out; + var previousErr = Console.Error; + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + Console.SetOut(stdout); + Console.SetError(stderr); + + try + { + var exitCode = action(); + return (exitCode, stdout.ToString(), stderr.ToString()); + } + finally + { + Console.SetOut(previousOut); + Console.SetError(previousErr); + } + } + public static (int ExitCode, string Text) CaptureWithInput(string input, Func action) { ArgumentNullException.ThrowIfNull(input); @@ -45,6 +68,36 @@ public static (int ExitCode, string Text) CaptureWithInput(string input, Func action) + { + ArgumentNullException.ThrowIfNull(input); + ArgumentNullException.ThrowIfNull(action); + + var previousOut = Console.Out; + var previousErr = Console.Error; + var previousIn = Console.In; + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + using var reader = new StringReader(input); + Console.SetOut(stdout); + Console.SetError(stderr); + Console.SetIn(reader); + + try + { + var exitCode = action(); + return (exitCode, stdout.ToString(), stderr.ToString()); + } + finally + { + Console.SetOut(previousOut); + Console.SetError(previousErr); + Console.SetIn(previousIn); + } + } + public static async Task<(int ExitCode, string Text)> CaptureAsync(Func> action) { ArgumentNullException.ThrowIfNull(action); diff --git a/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs b/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs new file mode 100644 index 0000000..0f03aff --- /dev/null +++ b/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs @@ -0,0 +1,151 @@ +namespace Repl.IntegrationTests; + +[TestClass] +[DoNotParallelize] +public sealed class Given_ProtocolPassthrough +{ + [TestMethod] + [Description("Regression guard: verifies protocol passthrough suppresses banners so stdout only contains handler protocol output.")] + public void When_CommandIsProtocolPassthrough_Then_GlobalAndCommandBannersAreSuppressed() + { + var sut = ReplApp.Create() + .WithDescription("Test banner"); + sut.Map( + "mcp start", + () => + { + Console.Out.WriteLine("rpc-ready"); + return Results.Exit(0); + }) + .WithBanner("Command banner") + .AsProtocolPassthrough(); + + var output = ConsoleCaptureHelper.CaptureStdOutAndErr(() => sut.Run(["mcp", "start"])); + + output.ExitCode.Should().Be(0); + output.StdOut.Should().Contain("rpc-ready"); + output.StdOut.Should().NotContain("Test banner"); + output.StdOut.Should().NotContain("Command banner"); + output.StdErr.Should().BeNullOrWhiteSpace(); + } + + [TestMethod] + [Description("Regression guard: verifies protocol passthrough routes repl diagnostics to stderr while keeping stdout clean.")] + public void When_ProtocolPassthroughHandlerFails_Then_ReplDiagnosticsAreWrittenToStderr() + { + var sut = ReplApp.Create() + .WithDescription("Test banner"); + sut.Map( + "mcp start", + static string () => throw new InvalidOperationException("invalid start")) + .AsProtocolPassthrough(); + + var output = ConsoleCaptureHelper.CaptureStdOutAndErr(() => sut.Run(["mcp", "start"])); + + output.ExitCode.Should().Be(1); + output.StdOut.Should().BeNullOrWhiteSpace(); + output.StdErr.Should().Contain("Validation: invalid start"); + output.StdErr.Should().NotContain("Test banner"); + } + + [TestMethod] + [Description("Regression guard: verifies protocol passthrough keeps explicit exit results silent while preserving the exit code.")] + public void When_ProtocolPassthroughReturnsExitWithoutPayload_Then_ExitCodeIsPropagatedWithoutFrameworkOutput() + { + var sut = ReplApp.Create() + .WithDescription("Test banner"); + sut.Map("mcp start", () => Results.Exit(7)) + .AsProtocolPassthrough(); + + var output = ConsoleCaptureHelper.CaptureStdOutAndErr(() => sut.Run(["mcp", "start"])); + + output.ExitCode.Should().Be(7); + output.StdOut.Should().BeNullOrWhiteSpace(); + output.StdErr.Should().BeNullOrWhiteSpace(); + } + + [TestMethod] + [Description("Regression guard: verifies protocol passthrough ignores interactive follow-up so stdin remains available to the handler lifecycle.")] + public void When_ProtocolPassthroughIsInvokedWithInteractiveFlag_Then_InteractiveLoopIsNotStarted() + { + var sut = ReplApp.Create() + .WithDescription("Test banner"); + sut.Map("mcp start", () => Results.Exit(0)) + .AsProtocolPassthrough(); + + var output = ConsoleCaptureHelper.CaptureWithInputStdOutAndErr( + "exit\n", + () => sut.Run(["mcp", "start", "--interactive"])); + + output.ExitCode.Should().Be(0); + output.StdOut.Should().BeNullOrWhiteSpace(); + output.StdErr.Should().BeNullOrWhiteSpace(); + } + + [TestMethod] + [Description("Regression guard: verifies protocol passthrough fails fast in hosted sessions when handler is console-bound.")] + public void When_ProtocolPassthroughRunsInHostedSessionWithoutIoContext_Then_RuntimeReturnsExplicitError() + { + var sut = ReplApp.Create(); + sut.Map("mcp start", () => Results.Exit(0)) + .AsProtocolPassthrough(); + using var input = new StringReader(string.Empty); + using var output = new StringWriter(); + var host = new InMemoryHost(input, output); + + var exitCode = sut.Run( + ["mcp", "start"], + host, + new ReplRunOptions { HostedServiceLifecycle = HostedServiceLifecycleMode.None }); + + exitCode.Should().Be(1); + output.ToString().Should().Contain("protocol passthrough"); + output.ToString().Should().Contain("IReplIoContext"); + } + + [TestMethod] + [Description("Regression guard: verifies hosted protocol passthrough works when handler requests IReplIoContext streams explicitly.")] + public void When_ProtocolPassthroughRunsInHostedSessionWithIoContext_Then_HandlerCanWriteToSessionStream() + { + var sut = ReplApp.Create(); + sut.Map( + "transfer send", + (IReplIoContext io) => + { + io.Output.WriteLine("zmodem-start"); + return Results.Exit(0); + }) + .AsProtocolPassthrough(); + using var input = new StringReader(string.Empty); + using var output = new StringWriter(); + var host = new InMemoryHost(input, output); + + var exitCode = sut.Run( + ["transfer", "send"], + host, + new ReplRunOptions { HostedServiceLifecycle = HostedServiceLifecycleMode.None }); + + exitCode.Should().Be(0); + output.ToString().Should().Contain("zmodem-start"); + } + + [TestMethod] + [Description("Regression guard: verifies IReplIoContext is injectable in normal CLI execution.")] + public void When_HandlerRequestsIoContextInCli_Then_RuntimeInjectsConsoleContext() + { + var sut = ReplApp.Create(); + sut.Map("io check", (IReplIoContext io) => io.IsHostedSession ? "hosted" : "local"); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["io", "check", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("local"); + } + + private sealed class InMemoryHost(TextReader input, TextWriter output) : IReplHost + { + public TextReader Input { get; } = input; + + public TextWriter Output { get; } = output; + } +} diff --git a/src/Repl.Tests/Given_CommandBuilder.cs b/src/Repl.Tests/Given_CommandBuilder.cs new file mode 100644 index 0000000..6895bc7 --- /dev/null +++ b/src/Repl.Tests/Given_CommandBuilder.cs @@ -0,0 +1,51 @@ +namespace Repl.Tests; + +[TestClass] +public sealed class Given_CommandBuilder +{ + [TestMethod] + [Description("Regression guard: verifies mapped commands are not protocol passthrough by default.")] + public void When_CommandIsMapped_Then_ProtocolPassthroughIsDisabledByDefault() + { + var sut = CoreReplApp.Create(); + + var command = sut.Map("status", () => "ok"); + + command.IsProtocolPassthrough.Should().BeFalse(); + } + + [TestMethod] + [Description("Regression guard: verifies fluent protocol passthrough API enables the route flag and preserves chaining.")] + public void When_AsProtocolPassthroughIsCalled_Then_FlagIsEnabledAndBuilderIsReturned() + { + var sut = CoreReplApp.Create(); + var command = sut.Map("mcp start", () => Results.Exit(0)); + + var chained = command.AsProtocolPassthrough(); + + chained.Should().BeSameAs(command); + command.IsProtocolPassthrough.Should().BeTrue(); + } + + [TestMethod] + [Description("Regression guard: verifies shell completion bridge route is marked as protocol passthrough.")] + public void When_ResolvingCompletionBridge_Then_CommandIsProtocolPassthrough() + { + var sut = CoreReplApp.Create(); + + var match = sut.Resolve( + [ + "completion", + "__complete", + "--shell", + "bash", + "--line", + "repl c", + "--cursor", + "6", + ]); + + match.Should().NotBeNull(); + match!.Route.Command.IsProtocolPassthrough.Should().BeTrue(); + } +} From 28ae1444c46c1b336416ea9f19eaa3096e6de4be Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 28 Feb 2026 16:18:40 -0500 Subject: [PATCH 2/7] Unify protocol passthrough IO semantics and docs --- README.md | 4 ++- docs/commands.md | 6 +++++ docs/shell-completion.md | 6 +++++ src/Repl.Core/CommandBuilder.cs | 2 ++ src/Repl.Core/CoreReplApp.cs | 9 +++---- src/Repl.Core/LiveReplIoContext.cs | 4 +-- src/Repl.Core/ReplSessionIO.cs | 27 ++++++++++++++++++- .../IShellCompletionRuntime.cs | 2 -- .../ShellCompletion/ShellCompletionModule.cs | 16 ++++++++++- .../ShellCompletion/ShellCompletionRuntime.cs | 5 ---- .../Given_ProtocolPassthrough.cs | 25 +++++++++++++++++ 11 files changed, 88 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index b49744c..8da1e2a 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,9 @@ app.Context("mcp", mcp => }); ``` -In passthrough mode, repl keeps `stdout` clean for protocol messages and sends framework diagnostics to `stderr`. +In passthrough mode, repl keeps `stdout` available for protocol messages and sends framework diagnostics to `stderr`. +If the handler requests `IReplIoContext`, write protocol payloads through `io.Output` (stdout in local CLI passthrough). +Requesting `IReplIoContext` is optional for local CLI handlers that already use `Console.*` directly, but recommended for explicit stream control, better testability, and hosted-session support. This is a strong fit for MCP-style stdio servers where the protocol stream must stay pristine. Typical shape is `mytool mcp start` in passthrough mode, while `mytool start` stays a normal CLI command. It also maps well to DAP/CGI-style stdio flows; socket-first variants (for example FastCGI/FastAGI) usually do not require passthrough. diff --git a/docs/commands.md b/docs/commands.md index 3304803..5f7e927 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -89,6 +89,12 @@ Execution scope note: - protocol passthrough works out of the box for **local CLI/console execution** - hosted terminal sessions (`IReplHost` / remote transports) require handlers to request `IReplIoContext`; console-bound toolings that use `Console.*` directly remain CLI-only +Why `IReplIoContext` is optional: + +- many protocol SDKs (for example some MCP/JSON-RPC stacks) read/write `Console.*` directly; these handlers can still work in local CLI passthrough without extra plumbing +- requesting `IReplIoContext` is the recommended low-level path when you want explicit stream control, easier testing, or hosted-session support +- in local CLI passthrough, `io.Output` is the protocol stream (`stdout`), while framework diagnostics remain on `stderr` + In protocol passthrough mode: - global and command banners are suppressed diff --git a/docs/shell-completion.md b/docs/shell-completion.md index 19fdc8c..385e9a0 100644 --- a/docs/shell-completion.md +++ b/docs/shell-completion.md @@ -13,6 +13,12 @@ completion __complete --shell --line --cur The shell passes current line + cursor, and Repl returns candidates on `stdout` (one per line). `completion __complete` is mapped in the regular command graph through the shell-completion module (CLI channel only). The bridge route is marked as protocol passthrough, so repl suppresses banners and routes framework diagnostics to `stderr`. +The bridge handler writes candidates through `IReplIoContext.Output`, which remains bound to the protocol stream (`stdout`) in local CLI passthrough. + +`IReplIoContext` is optional in general protocol commands: + +- optional for local CLI commands that already use `Console.*` directly +- recommended when handlers need explicit stream injection, deterministic tests, or hosted-session compatibility The module exposes a real `completion` context scope: diff --git a/src/Repl.Core/CommandBuilder.cs b/src/Repl.Core/CommandBuilder.cs index 831f851..5440207 100644 --- a/src/Repl.Core/CommandBuilder.cs +++ b/src/Repl.Core/CommandBuilder.cs @@ -154,6 +154,8 @@ public CommandBuilder Hidden(bool isHidden = true) /// /// Marks this command as protocol passthrough. /// In this mode, repl diagnostics are routed to stderr and interactive stdin reads are skipped. + /// When handlers request , remains the protocol stream + /// (stdout in local CLI passthrough), while framework output stays on stderr. /// For hosted sessions, handlers should request to access transport streams explicitly. /// /// The same builder instance. diff --git a/src/Repl.Core/CoreReplApp.cs b/src/Repl.Core/CoreReplApp.cs index a922b8c..5d4b940 100644 --- a/src/Repl.Core/CoreReplApp.cs +++ b/src/Repl.Core/CoreReplApp.cs @@ -458,11 +458,6 @@ private async ValueTask ExecuteCoreAsync( private bool ShouldSuppressGlobalBanner(GlobalInvocationOptions globalOptions) { - if (_shellCompletionRuntime.IsBridgeInvocation(globalOptions.RemainingTokens)) - { - return true; - } - if (globalOptions.HelpRequested || globalOptions.RemainingTokens.Count == 0) { return false; @@ -566,7 +561,9 @@ private async ValueTask ExecuteProtocolPassthroughCommandAsync( using var protocolScope = ReplSessionIO.SetSession( Console.Error, Console.In, - ansiMode: AnsiMode.Never); + ansiMode: AnsiMode.Never, + commandOutput: Console.Out, + error: Console.Error); return await ExecuteMatchedCommandAsync( match, globalOptions, diff --git a/src/Repl.Core/LiveReplIoContext.cs b/src/Repl.Core/LiveReplIoContext.cs index b54a0c0..576bb02 100644 --- a/src/Repl.Core/LiveReplIoContext.cs +++ b/src/Repl.Core/LiveReplIoContext.cs @@ -4,9 +4,9 @@ internal sealed class LiveReplIoContext : IReplIoContext { public TextReader Input => ReplSessionIO.Input; - public TextWriter Output => ReplSessionIO.Output; + public TextWriter Output => ReplSessionIO.CommandOutput; - public TextWriter Error => ReplSessionIO.IsSessionActive ? ReplSessionIO.Output : Console.Error; + public TextWriter Error => ReplSessionIO.Error; public bool IsHostedSession => ReplSessionIO.IsSessionActive; diff --git a/src/Repl.Core/ReplSessionIO.cs b/src/Repl.Core/ReplSessionIO.cs index 6aff832..3049e3c 100644 --- a/src/Repl.Core/ReplSessionIO.cs +++ b/src/Repl.Core/ReplSessionIO.cs @@ -22,6 +22,8 @@ internal readonly record struct SessionMetadata( DateTimeOffset LastUpdatedUtc); private static readonly AsyncLocal s_output = new(); + private static readonly AsyncLocal s_error = new(); + private static readonly AsyncLocal s_commandOutput = new(); private static readonly AsyncLocal s_input = new(); private static readonly AsyncLocal s_keyReader = new(); private static readonly AsyncLocal s_sessionId = new(); @@ -32,6 +34,17 @@ internal readonly record struct SessionMetadata( /// public static TextWriter Output => s_output.Value ?? Console.Out; + /// + /// Gets the current session error writer, or when no session is active. + /// + public static TextWriter Error => s_error.Value ?? Console.Error; + + /// + /// Gets the handler output writer. In protocol passthrough mode this can remain bound to stdout + /// while framework output is redirected to stderr. + /// + public static TextWriter CommandOutput => s_commandOutput.Value ?? Output; + /// /// Gets the current session input reader, or when no session is active. /// @@ -180,12 +193,16 @@ public static IDisposable SetSession( TextWriter output, TextReader input, AnsiMode ansiMode = AnsiMode.Auto, - string? sessionId = null) + string? sessionId = null, + TextWriter? commandOutput = null, + TextWriter? error = null) { ArgumentNullException.ThrowIfNull(output); ArgumentNullException.ThrowIfNull(input); var previousOutput = s_output.Value; + var previousError = s_error.Value; + var previousCommandOutput = s_commandOutput.Value; var previousInput = s_input.Value; var previousKeyReader = s_keyReader.Value; var previousSessionId = s_sessionId.Value; @@ -196,6 +213,8 @@ public static IDisposable SetSession( EnsureSession(resolvedSessionId); s_output.Value = output; + s_error.Value = error ?? output; + s_commandOutput.Value = commandOutput ?? output; s_input.Value = input; s_sessionId.Value = resolvedSessionId; @@ -222,6 +241,8 @@ public static IDisposable SetSession( return new SessionScope( previousOutput, + previousError, + previousCommandOutput, previousInput, previousKeyReader, previousSessionId, @@ -293,6 +314,8 @@ private static SessionMetadata NormalizeSession(string sessionId, SessionMetadat private sealed class SessionScope( TextWriter? previousOutput, + TextWriter? previousError, + TextWriter? previousCommandOutput, TextReader? previousInput, IReplKeyReader? previousKeyReader, string? previousSessionId, @@ -302,6 +325,8 @@ private sealed class SessionScope( public void Dispose() { s_output.Value = previousOutput; + s_error.Value = previousError; + s_commandOutput.Value = previousCommandOutput; s_input.Value = previousInput; s_keyReader.Value = previousKeyReader; s_sessionId.Value = previousSessionId; diff --git a/src/Repl.Core/ShellCompletion/IShellCompletionRuntime.cs b/src/Repl.Core/ShellCompletion/IShellCompletionRuntime.cs index 7b5b209..2d52ab6 100644 --- a/src/Repl.Core/ShellCompletion/IShellCompletionRuntime.cs +++ b/src/Repl.Core/ShellCompletion/IShellCompletionRuntime.cs @@ -22,6 +22,4 @@ ValueTask HandleUninstallRouteAsync( ValueTask HandleStartupAsync( IServiceProvider serviceProvider, CancellationToken cancellationToken); - - bool IsBridgeInvocation(IReadOnlyList tokens); } diff --git a/src/Repl.Core/ShellCompletion/ShellCompletionModule.cs b/src/Repl.Core/ShellCompletion/ShellCompletionModule.cs index d69607f..622c572 100644 --- a/src/Repl.Core/ShellCompletion/ShellCompletionModule.cs +++ b/src/Repl.Core/ShellCompletion/ShellCompletionModule.cs @@ -14,7 +14,21 @@ public void Map(IReplMap map) { completion.Map( ShellCompletionConstants.ProtocolSubcommandName, - (string? shell, string? line, string? cursor) => runtime.HandleBridgeRoute(shell, line, cursor)) + async (string? shell, string? line, string? cursor, IReplIoContext io) => + { + var result = runtime.HandleBridgeRoute(shell, line, cursor); + if (result is not string payload) + { + return result; + } + + if (!string.IsNullOrEmpty(payload)) + { + await io.Output.WriteLineAsync(payload).ConfigureAwait(false); + } + + return Results.Exit(0); + }) .WithDescription("Internal completion bridge used by shell integrations.") .AsProtocolPassthrough() .Hidden(); diff --git a/src/Repl.Core/ShellCompletion/ShellCompletionRuntime.cs b/src/Repl.Core/ShellCompletion/ShellCompletionRuntime.cs index 963e62f..388517e 100644 --- a/src/Repl.Core/ShellCompletion/ShellCompletionRuntime.cs +++ b/src/Repl.Core/ShellCompletion/ShellCompletionRuntime.cs @@ -285,11 +285,6 @@ await ReplSessionIO.Output.WriteLineAsync( } } - public bool IsBridgeInvocation(IReadOnlyList tokens) => - tokens.Count >= 2 - && string.Equals(tokens[0], ShellCompletionConstants.SetupCommandName, StringComparison.OrdinalIgnoreCase) - && string.Equals(tokens[1], ShellCompletionConstants.ProtocolSubcommandName, StringComparison.OrdinalIgnoreCase); - private IReplResult? ValidateShellCompletionManagementAvailability() { if (!_options.ShellCompletion.Enabled) diff --git a/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs b/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs index 0f03aff..4c4e90a 100644 --- a/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs +++ b/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs @@ -29,6 +29,31 @@ public void When_CommandIsProtocolPassthrough_Then_GlobalAndCommandBannersAreSup output.StdErr.Should().BeNullOrWhiteSpace(); } + [TestMethod] + [Description("Regression guard: verifies IReplIoContext output stays on stdout in CLI protocol passthrough while framework output remains redirected.")] + public void When_ProtocolPassthroughHandlerUsesIoContext_Then_OutputIsWrittenToStdOut() + { + var sut = ReplApp.Create() + .WithDescription("Test banner"); + sut.Map( + "mcp start", + (IReplIoContext io) => + { + io.Output.WriteLine("rpc-ready"); + return Results.Exit(0); + }) + .WithBanner("Command banner") + .AsProtocolPassthrough(); + + var output = ConsoleCaptureHelper.CaptureStdOutAndErr(() => sut.Run(["mcp", "start"])); + + output.ExitCode.Should().Be(0); + output.StdOut.Should().Contain("rpc-ready"); + output.StdOut.Should().NotContain("Test banner"); + output.StdOut.Should().NotContain("Command banner"); + output.StdErr.Should().BeNullOrWhiteSpace(); + } + [TestMethod] [Description("Regression guard: verifies protocol passthrough routes repl diagnostics to stderr while keeping stdout clean.")] public void When_ProtocolPassthroughHandlerFails_Then_ReplDiagnosticsAreWrittenToStderr() From fa37df0dff5b780f128d2955e75243e863812e4f Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 28 Feb 2026 16:30:30 -0500 Subject: [PATCH 3/7] Optimize passthrough routing and harden protocol IO behavior --- src/Repl.Core/CommandBuilder.cs | 27 ++++ src/Repl.Core/CoreReplApp.cs | 45 +++--- src/Repl.Core/LiveReplIoContext.cs | 3 + src/Repl.Core/ReplSessionIO.cs | 4 + .../ShellCompletion/ShellCompletionModule.cs | 1 + .../ConsoleCaptureHelper.cs | 144 +++++++++++------- .../Given_ProtocolPassthrough.cs | 14 ++ 7 files changed, 161 insertions(+), 77 deletions(-) diff --git a/src/Repl.Core/CommandBuilder.cs b/src/Repl.Core/CommandBuilder.cs index 5440207..9f98be0 100644 --- a/src/Repl.Core/CommandBuilder.cs +++ b/src/Repl.Core/CommandBuilder.cs @@ -17,6 +17,7 @@ internal CommandBuilder(string route, Delegate handler) { Route = route; Handler = handler; + SupportsHostedProtocolPassthrough = ComputeSupportsHostedProtocolPassthrough(handler); } /// @@ -54,6 +55,11 @@ internal CommandBuilder(string route, Delegate handler) /// public bool IsProtocolPassthrough { get; private set; } + /// + /// Gets a value indicating whether the handler can run protocol passthrough in hosted sessions. + /// + internal bool SupportsHostedProtocolPassthrough { get; } + /// /// Gets the banner delegate rendered before command execution. /// @@ -164,4 +170,25 @@ public CommandBuilder AsProtocolPassthrough() IsProtocolPassthrough = true; return this; } + + private static bool ComputeSupportsHostedProtocolPassthrough(Delegate handler) + { + foreach (var parameter in handler.Method.GetParameters()) + { + if (parameter.ParameterType != typeof(IReplIoContext)) + { + continue; + } + + // [FromContext] binds route/context values and is not stream injection. + if (parameter.GetCustomAttributes(typeof(FromContextAttribute), inherit: true).Length > 0) + { + continue; + } + + return true; + } + + return false; + } } diff --git a/src/Repl.Core/CoreReplApp.cs b/src/Repl.Core/CoreReplApp.cs index 5d4b940..23392dd 100644 --- a/src/Repl.Core/CoreReplApp.cs +++ b/src/Repl.Core/CoreReplApp.cs @@ -407,7 +407,8 @@ private async ValueTask ExecuteCoreAsync( using var runtimeStateScope = PushRuntimeState(serviceProvider, isInteractiveSession: false); var prefixResolution = ResolveUniquePrefixes(globalOptions.RemainingTokens); var resolvedGlobalOptions = globalOptions with { RemainingTokens = prefixResolution.Tokens }; - if (!ShouldSuppressGlobalBanner(resolvedGlobalOptions)) + var preResolvedRouteResolution = TryPreResolveRouteForBanner(resolvedGlobalOptions); + if (!ShouldSuppressGlobalBanner(resolvedGlobalOptions, preResolvedRouteResolution?.Match)) { await TryRenderBannerAsync(resolvedGlobalOptions, serviceProvider, cancellationToken).ConfigureAwait(false); } @@ -430,7 +431,8 @@ private async ValueTask ExecuteCoreAsync( return preExecutionExitCode.Value; } - var resolution = ResolveWithDiagnostics(resolvedGlobalOptions.RemainingTokens); + var resolution = preResolvedRouteResolution + ?? ResolveWithDiagnostics(resolvedGlobalOptions.RemainingTokens); var match = resolution.Match; if (match is null) { @@ -456,15 +458,26 @@ private async ValueTask ExecuteCoreAsync( } } - private bool ShouldSuppressGlobalBanner(GlobalInvocationOptions globalOptions) + private static bool ShouldSuppressGlobalBanner( + GlobalInvocationOptions globalOptions, + RouteMatch? preResolvedMatch) { if (globalOptions.HelpRequested || globalOptions.RemainingTokens.Count == 0) { return false; } - var match = Resolve(globalOptions.RemainingTokens); - return match?.Route.Command.IsProtocolPassthrough == true; + return preResolvedMatch?.Route.Command.IsProtocolPassthrough == true; + } + + private RouteResolver.RouteResolutionResult? TryPreResolveRouteForBanner(GlobalInvocationOptions globalOptions) + { + if (globalOptions.HelpRequested || globalOptions.RemainingTokens.Count == 0) + { + return null; + } + + return ResolveWithDiagnostics(globalOptions.RemainingTokens); } private async ValueTask TryHandlePreExecutionAsync( @@ -503,7 +516,7 @@ private async ValueTask ExecuteMatchedCommandAndMaybeEnterInteractiveAsync( { if (match.Route.Command.IsProtocolPassthrough && ReplSessionIO.IsSessionActive - && !SupportsHostedProtocolPassthrough(match.Route.Command.Handler)) + && !match.Route.Command.SupportsHostedProtocolPassthrough) { _ = await RenderOutputAsync( Results.Error( @@ -573,26 +586,6 @@ private async ValueTask ExecuteProtocolPassthroughCommandAsync( .ConfigureAwait(false); } - private static bool SupportsHostedProtocolPassthrough(Delegate handler) - { - foreach (var parameter in handler.Method.GetParameters()) - { - if (parameter.ParameterType != typeof(IReplIoContext)) - { - continue; - } - - if (parameter.GetCustomAttributes(typeof(FromContextAttribute), inherit: true).Length > 0) - { - continue; - } - - return true; - } - - return false; - } - private async ValueTask HandleEmptyInvocationAsync( GlobalInvocationOptions globalOptions, IServiceProvider serviceProvider, diff --git a/src/Repl.Core/LiveReplIoContext.cs b/src/Repl.Core/LiveReplIoContext.cs index 576bb02..6ccc26f 100644 --- a/src/Repl.Core/LiveReplIoContext.cs +++ b/src/Repl.Core/LiveReplIoContext.cs @@ -1,5 +1,8 @@ namespace Repl; +/// +/// Live view backed by the current async-local state. +/// internal sealed class LiveReplIoContext : IReplIoContext { public TextReader Input => ReplSessionIO.Input; diff --git a/src/Repl.Core/ReplSessionIO.cs b/src/Repl.Core/ReplSessionIO.cs index 3049e3c..fb5c919 100644 --- a/src/Repl.Core/ReplSessionIO.cs +++ b/src/Repl.Core/ReplSessionIO.cs @@ -43,6 +43,9 @@ internal readonly record struct SessionMetadata( /// Gets the handler output writer. In protocol passthrough mode this can remain bound to stdout /// while framework output is redirected to stderr. /// + /// + /// Falls back to , which itself falls back to . + /// public static TextWriter CommandOutput => s_commandOutput.Value ?? Output; /// @@ -213,6 +216,7 @@ public static IDisposable SetSession( EnsureSession(resolvedSessionId); s_output.Value = output; + // Default to the active session output for hosted flows unless a separate error writer is supplied. s_error.Value = error ?? output; s_commandOutput.Value = commandOutput ?? output; s_input.Value = input; diff --git a/src/Repl.Core/ShellCompletion/ShellCompletionModule.cs b/src/Repl.Core/ShellCompletion/ShellCompletionModule.cs index 622c572..beb3d5f 100644 --- a/src/Repl.Core/ShellCompletion/ShellCompletionModule.cs +++ b/src/Repl.Core/ShellCompletion/ShellCompletionModule.cs @@ -19,6 +19,7 @@ public void Map(IReplMap map) var result = runtime.HandleBridgeRoute(shell, line, cursor); if (result is not string payload) { + // Error/validation results are rendered by the framework (stderr in passthrough mode). return result; } diff --git a/src/Repl.IntegrationTests/ConsoleCaptureHelper.cs b/src/Repl.IntegrationTests/ConsoleCaptureHelper.cs index a9f3b35..b2357f5 100644 --- a/src/Repl.IntegrationTests/ConsoleCaptureHelper.cs +++ b/src/Repl.IntegrationTests/ConsoleCaptureHelper.cs @@ -2,45 +2,63 @@ namespace Repl.IntegrationTests; internal static class ConsoleCaptureHelper { + private static readonly SemaphoreSlim s_consoleLock = new(1, 1); + public static (int ExitCode, string Text) Capture(Func action) { ArgumentNullException.ThrowIfNull(action); - - var previous = Console.Out; - using var writer = new StringWriter(); - Console.SetOut(writer); + s_consoleLock.Wait(); try { - var exitCode = action(); - return (exitCode, writer.ToString()); + var previous = Console.Out; + using var writer = new StringWriter(); + Console.SetOut(writer); + + try + { + var exitCode = action(); + return (exitCode, writer.ToString()); + } + finally + { + Console.SetOut(previous); + } } finally { - Console.SetOut(previous); + s_consoleLock.Release(); } } public static (int ExitCode, string StdOut, string StdErr) CaptureStdOutAndErr(Func action) { ArgumentNullException.ThrowIfNull(action); - - var previousOut = Console.Out; - var previousErr = Console.Error; - using var stdout = new StringWriter(); - using var stderr = new StringWriter(); - Console.SetOut(stdout); - Console.SetError(stderr); + s_consoleLock.Wait(); try { - var exitCode = action(); - return (exitCode, stdout.ToString(), stderr.ToString()); + var previousOut = Console.Out; + var previousErr = Console.Error; + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + Console.SetOut(stdout); + Console.SetError(stderr); + + try + { + var exitCode = action(); + return (exitCode, stdout.ToString(), stderr.ToString()); + } + finally + { + Console.SetOut(previousOut); + Console.SetError(previousErr); + } } finally { - Console.SetOut(previousOut); - Console.SetError(previousErr); + s_consoleLock.Release(); } } @@ -48,23 +66,31 @@ public static (int ExitCode, string Text) CaptureWithInput(string input, Func CaptureAsync(Func> action) { ArgumentNullException.ThrowIfNull(action); - - var previous = Console.Out; - using var writer = new StringWriter(); - Console.SetOut(writer); + await s_consoleLock.WaitAsync().ConfigureAwait(false); try { - var exitCode = await action().ConfigureAwait(false); - return (exitCode, writer.ToString()); + var previous = Console.Out; + using var writer = new StringWriter(); + Console.SetOut(writer); + + try + { + var exitCode = await action().ConfigureAwait(false); + return (exitCode, writer.ToString()); + } + finally + { + Console.SetOut(previous); + } } finally { - Console.SetOut(previous); + s_consoleLock.Release(); } } } diff --git a/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs b/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs index 4c4e90a..15151fb 100644 --- a/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs +++ b/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs @@ -73,6 +73,20 @@ public void When_ProtocolPassthroughHandlerFails_Then_ReplDiagnosticsAreWrittenT output.StdErr.Should().NotContain("Test banner"); } + [TestMethod] + [Description("Regression guard: verifies shell completion bridge protocol errors are rendered by framework on stderr in passthrough mode.")] + public void When_CompletionBridgeUsageIsInvalid_Then_ErrorIsWrittenToStderr() + { + var sut = ReplApp.Create(); + + var output = ConsoleCaptureHelper.CaptureStdOutAndErr( + () => sut.Run(["completion", "__complete", "--shell", "bash", "--line", "repl ping", "--cursor", "invalid"])); + + output.ExitCode.Should().Be(1); + output.StdOut.Should().BeNullOrWhiteSpace(); + output.StdErr.Should().Contain("usage: completion __complete"); + } + [TestMethod] [Description("Regression guard: verifies protocol passthrough keeps explicit exit results silent while preserving the exit code.")] public void When_ProtocolPassthroughReturnsExitWithoutPayload_Then_ExitCodeIsPropagatedWithoutFrameworkOutput() From 9a19b3ef1bc1cf25ec69aa92de0684a9ef4d98f3 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 28 Feb 2026 16:41:25 -0500 Subject: [PATCH 4/7] Fix IsHostedSession semantics in CLI passthrough --- src/Repl.Core/CoreReplApp.cs | 5 +++-- src/Repl.Core/IReplIoContext.cs | 3 ++- src/Repl.Core/LiveReplIoContext.cs | 2 +- src/Repl.Core/ReplSessionIO.cs | 14 +++++++++++- .../Given_ProtocolPassthrough.cs | 22 +++++++++++++++++++ 5 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/Repl.Core/CoreReplApp.cs b/src/Repl.Core/CoreReplApp.cs index 23392dd..eea723e 100644 --- a/src/Repl.Core/CoreReplApp.cs +++ b/src/Repl.Core/CoreReplApp.cs @@ -515,7 +515,7 @@ private async ValueTask ExecuteMatchedCommandAndMaybeEnterInteractiveAsync( CancellationToken cancellationToken) { if (match.Route.Command.IsProtocolPassthrough - && ReplSessionIO.IsSessionActive + && ReplSessionIO.IsHostedSession && !match.Route.Command.SupportsHostedProtocolPassthrough) { _ = await RenderOutputAsync( @@ -576,7 +576,8 @@ private async ValueTask ExecuteProtocolPassthroughCommandAsync( Console.In, ansiMode: AnsiMode.Never, commandOutput: Console.Out, - error: Console.Error); + error: Console.Error, + isHostedSession: false); return await ExecuteMatchedCommandAsync( match, globalOptions, diff --git a/src/Repl.Core/IReplIoContext.cs b/src/Repl.Core/IReplIoContext.cs index fd42625..5810aad 100644 --- a/src/Repl.Core/IReplIoContext.cs +++ b/src/Repl.Core/IReplIoContext.cs @@ -21,7 +21,8 @@ public interface IReplIoContext TextWriter Error { get; } /// - /// Gets a value indicating whether execution is currently running in a hosted session. + /// Gets a value indicating whether execution is currently running in a real hosted transport session. + /// This is false for local CLI execution, including protocol passthrough scopes. /// bool IsHostedSession { get; } diff --git a/src/Repl.Core/LiveReplIoContext.cs b/src/Repl.Core/LiveReplIoContext.cs index 6ccc26f..cc681a3 100644 --- a/src/Repl.Core/LiveReplIoContext.cs +++ b/src/Repl.Core/LiveReplIoContext.cs @@ -11,7 +11,7 @@ internal sealed class LiveReplIoContext : IReplIoContext public TextWriter Error => ReplSessionIO.Error; - public bool IsHostedSession => ReplSessionIO.IsSessionActive; + public bool IsHostedSession => ReplSessionIO.IsHostedSession; public string? SessionId => ReplSessionIO.CurrentSessionId; } diff --git a/src/Repl.Core/ReplSessionIO.cs b/src/Repl.Core/ReplSessionIO.cs index fb5c919..1aacf84 100644 --- a/src/Repl.Core/ReplSessionIO.cs +++ b/src/Repl.Core/ReplSessionIO.cs @@ -26,6 +26,7 @@ internal readonly record struct SessionMetadata( private static readonly AsyncLocal s_commandOutput = new(); private static readonly AsyncLocal s_input = new(); private static readonly AsyncLocal s_keyReader = new(); + private static readonly AsyncLocal s_isHostedSession = new(); private static readonly AsyncLocal s_sessionId = new(); private static readonly ConcurrentDictionary s_sessions = new(StringComparer.Ordinal); @@ -58,6 +59,11 @@ internal readonly record struct SessionMetadata( /// public static bool IsSessionActive => s_output.Value is not null && !string.IsNullOrWhiteSpace(s_sessionId.Value); + /// + /// Gets a value indicating whether execution is currently running in a real hosted transport session. + /// + public static bool IsHostedSession => s_isHostedSession.Value; + /// /// Gets the current hosted session identifier, when available. /// @@ -198,7 +204,8 @@ public static IDisposable SetSession( AnsiMode ansiMode = AnsiMode.Auto, string? sessionId = null, TextWriter? commandOutput = null, - TextWriter? error = null) + TextWriter? error = null, + bool isHostedSession = true) { ArgumentNullException.ThrowIfNull(output); ArgumentNullException.ThrowIfNull(input); @@ -208,6 +215,7 @@ public static IDisposable SetSession( var previousCommandOutput = s_commandOutput.Value; var previousInput = s_input.Value; var previousKeyReader = s_keyReader.Value; + var previousIsHostedSession = s_isHostedSession.Value; var previousSessionId = s_sessionId.Value; var resolvedSessionId = string.IsNullOrWhiteSpace(sessionId) @@ -220,6 +228,7 @@ public static IDisposable SetSession( s_error.Value = error ?? output; s_commandOutput.Value = commandOutput ?? output; s_input.Value = input; + s_isHostedSession.Value = isHostedSession; s_sessionId.Value = resolvedSessionId; if (ansiMode == AnsiMode.Always) @@ -249,6 +258,7 @@ public static IDisposable SetSession( previousCommandOutput, previousInput, previousKeyReader, + previousIsHostedSession, previousSessionId, removeSessionOnDispose: string.IsNullOrWhiteSpace(sessionId), sessionIdToRemove: resolvedSessionId); @@ -322,6 +332,7 @@ private sealed class SessionScope( TextWriter? previousCommandOutput, TextReader? previousInput, IReplKeyReader? previousKeyReader, + bool previousIsHostedSession, string? previousSessionId, bool removeSessionOnDispose, string sessionIdToRemove) : IDisposable @@ -333,6 +344,7 @@ public void Dispose() s_commandOutput.Value = previousCommandOutput; s_input.Value = previousInput; s_keyReader.Value = previousKeyReader; + s_isHostedSession.Value = previousIsHostedSession; s_sessionId.Value = previousSessionId; if (removeSessionOnDispose) diff --git a/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs b/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs index 15151fb..dfb3225 100644 --- a/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs +++ b/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs @@ -181,6 +181,28 @@ public void When_HandlerRequestsIoContextInCli_Then_RuntimeInjectsConsoleContext output.Text.Should().Contain("local"); } + [TestMethod] + [Description("Regression guard: verifies CLI protocol passthrough does not report a hosted session through IReplIoContext.")] + public void When_HandlerRequestsIoContextInCliProtocolPassthrough_Then_IsHostedSessionIsFalse() + { + var sut = ReplApp.Create(); + sut.Map( + "io protocol-check", + (IReplIoContext io) => + { + io.Output.WriteLine(io.IsHostedSession ? "hosted" : "local"); + return Results.Exit(0); + }) + .AsProtocolPassthrough(); + + var output = ConsoleCaptureHelper.CaptureStdOutAndErr( + () => sut.Run(["io", "protocol-check", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.StdOut.Should().Contain("local"); + output.StdErr.Should().NotContain("hosted"); + } + private sealed class InMemoryHost(TextReader input, TextWriter output) : IReplHost { public TextReader Input { get; } = input; From b09cefaac9a93295276ccca457e2c92711b189fd Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 28 Feb 2026 16:47:22 -0500 Subject: [PATCH 5/7] Polish protocol passthrough docs and IO edge cases --- README.md | 3 +- docs/commands.md | 10 ++- src/Repl.Core/CancelKeyHandler.cs | 4 +- .../Given_ProtocolPassthrough.cs | 72 +++++++++++++++++++ src/Repl.Tests/Given_CancelKeyHandler.cs | 46 ++++++++++++ 5 files changed, 130 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8da1e2a..4413e0b 100644 --- a/README.md +++ b/README.md @@ -185,9 +185,10 @@ app.Context("mcp", mcp => In passthrough mode, repl keeps `stdout` available for protocol messages and sends framework diagnostics to `stderr`. If the handler requests `IReplIoContext`, write protocol payloads through `io.Output` (stdout in local CLI passthrough). Requesting `IReplIoContext` is optional for local CLI handlers that already use `Console.*` directly, but recommended for explicit stream control, better testability, and hosted-session support. +Framework-rendered handler return payloads (if any) are also written to `stderr` in passthrough mode, so protocol handlers should usually return `Results.Exit(code)` after writing protocol output. This is a strong fit for MCP-style stdio servers where the protocol stream must stay pristine. Typical shape is `mytool mcp start` in passthrough mode, while `mytool start` stays a normal CLI command. -It also maps well to DAP/CGI-style stdio flows; socket-first variants (for example FastCGI/FastAGI) usually do not require passthrough. +It also maps well to DAP/CGI-style stdio flows; socket-first variants (for example FastCGI) usually do not require passthrough. Use this mode directly in local CLI/console runs. For hosted terminal sessions (`IReplHost` / remote transports), handlers should request `IReplIoContext`; console-bound toolings that use `Console.*` directly remain CLI-only. One-shot CLI: diff --git a/docs/commands.md b/docs/commands.md index 5f7e927..5e9980f 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -78,11 +78,11 @@ Common protocol families that fit this mode: - MCP over stdio - LSP / JSON-RPC over stdio - DAP over stdio -- CGI-style process protocols (stdin/stdout contract), including AGI-style integrations +- CGI-style process protocols (stdin/stdout contract) Not typical passthrough targets: -- socket-first variants such as FastCGI/FastAGI (their protocol stream is on TCP, not app `stdout`) +- socket-first variants such as FastCGI (their protocol stream is on TCP, not app `stdout`) Execution scope note: @@ -99,5 +99,11 @@ In protocol passthrough mode: - global and command banners are suppressed - repl/framework diagnostics are written to `stderr` +- framework-rendered handler return payloads (if any) are also written to `stderr` - `stdout` remains reserved for protocol payloads - interactive follow-up is skipped after command execution + +Practical guidance: + +- for protocol commands, prefer writing protocol bytes/messages directly to `io.Output` (or `Console.Out` when SDK-bound) +- return `Results.Exit(code)` to keep framework rendering silent diff --git a/src/Repl.Core/CancelKeyHandler.cs b/src/Repl.Core/CancelKeyHandler.cs index 27e543e..120cd61 100644 --- a/src/Repl.Core/CancelKeyHandler.cs +++ b/src/Repl.Core/CancelKeyHandler.cs @@ -59,8 +59,8 @@ private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e) e.Cancel = true; _commandCts.Cancel(); _lastCancelPress = now; - Console.Error.WriteLine(); - Console.Error.WriteLine("Press Ctrl+C again to exit."); + ReplSessionIO.Error.WriteLine(); + ReplSessionIO.Error.WriteLine("Press Ctrl+C again to exit."); return; } diff --git a/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs b/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs index dfb3225..93a47ca 100644 --- a/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs +++ b/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs @@ -73,6 +73,21 @@ public void When_ProtocolPassthroughHandlerFails_Then_ReplDiagnosticsAreWrittenT output.StdErr.Should().NotContain("Test banner"); } + [TestMethod] + [Description("Regression guard: verifies framework-rendered handler return values are emitted on stderr in protocol passthrough mode.")] + public void When_ProtocolPassthroughHandlerReturnsPlainValue_Then_ValueIsRenderedToStderr() + { + var sut = ReplApp.Create(); + sut.Map("mcp info", () => "server-version-1.0") + .AsProtocolPassthrough(); + + var output = ConsoleCaptureHelper.CaptureStdOutAndErr(() => sut.Run(["mcp", "info"])); + + output.ExitCode.Should().Be(0); + output.StdOut.Should().BeNullOrWhiteSpace(); + output.StdErr.Should().Contain("server-version-1.0"); + } + [TestMethod] [Description("Regression guard: verifies shell completion bridge protocol errors are rendered by framework on stderr in passthrough mode.")] public void When_CompletionBridgeUsageIsInvalid_Then_ErrorIsWrittenToStderr() @@ -103,6 +118,37 @@ public void When_ProtocolPassthroughReturnsExitWithoutPayload_Then_ExitCodeIsPro output.StdErr.Should().BeNullOrWhiteSpace(); } + [TestMethod] + [Description("Regression guard: verifies --json still applies to framework-rendered payloads and is emitted on stderr in passthrough mode.")] + public void When_ProtocolPassthroughReturnsPayloadWithJsonFormat_Then_PayloadIsRenderedToStderrAsJson() + { + var sut = ReplApp.Create(); + sut.Map("mcp status", () => new Dictionary(StringComparer.Ordinal) { ["status"] = "ready" }) + .AsProtocolPassthrough(); + + var output = ConsoleCaptureHelper.CaptureStdOutAndErr(() => sut.Run(["mcp", "status", "--json"])); + + output.ExitCode.Should().Be(0); + output.StdOut.Should().BeNullOrWhiteSpace(); + output.StdErr.Should().Contain("\"status\""); + output.StdErr.Should().Contain("ready"); + } + + [TestMethod] + [Description("Regression guard: verifies --json remains inert for Results.Exit without payload in passthrough mode.")] + public void When_ProtocolPassthroughReturnsExitWithoutPayloadWithJsonFormat_Then_NoFrameworkOutputIsRendered() + { + var sut = ReplApp.Create(); + sut.Map("mcp start", () => Results.Exit(0)) + .AsProtocolPassthrough(); + + var output = ConsoleCaptureHelper.CaptureStdOutAndErr(() => sut.Run(["mcp", "start", "--json"])); + + output.ExitCode.Should().Be(0); + output.StdOut.Should().BeNullOrWhiteSpace(); + output.StdErr.Should().BeNullOrWhiteSpace(); + } + [TestMethod] [Description("Regression guard: verifies protocol passthrough ignores interactive follow-up so stdin remains available to the handler lifecycle.")] public void When_ProtocolPassthroughIsInvokedWithInteractiveFlag_Then_InteractiveLoopIsNotStarted() @@ -168,6 +214,32 @@ public void When_ProtocolPassthroughRunsInHostedSessionWithIoContext_Then_Handle output.ToString().Should().Contain("zmodem-start"); } + [TestMethod] + [Description("Regression guard: verifies hosted protocol passthrough reports IsHostedSession=true through IReplIoContext.")] + public void When_ProtocolPassthroughRunsInHostedSessionWithIoContext_Then_IsHostedSessionIsTrue() + { + var sut = ReplApp.Create(); + sut.Map( + "transfer check", + (IReplIoContext io) => + { + io.Output.WriteLine(io.IsHostedSession ? "hosted" : "local"); + return Results.Exit(0); + }) + .AsProtocolPassthrough(); + using var input = new StringReader(string.Empty); + using var output = new StringWriter(); + var host = new InMemoryHost(input, output); + + var exitCode = sut.Run( + ["transfer", "check"], + host, + new ReplRunOptions { HostedServiceLifecycle = HostedServiceLifecycleMode.None }); + + exitCode.Should().Be(0); + output.ToString().Should().Contain("hosted"); + } + [TestMethod] [Description("Regression guard: verifies IReplIoContext is injectable in normal CLI execution.")] public void When_HandlerRequestsIoContextInCli_Then_RuntimeInjectsConsoleContext() diff --git a/src/Repl.Tests/Given_CancelKeyHandler.cs b/src/Repl.Tests/Given_CancelKeyHandler.cs index 38438f8..44f7238 100644 --- a/src/Repl.Tests/Given_CancelKeyHandler.cs +++ b/src/Repl.Tests/Given_CancelKeyHandler.cs @@ -1,4 +1,5 @@ using AwesomeAssertions; +using System.Reflection; namespace Repl.Tests; @@ -31,4 +32,49 @@ public void When_CommandCtsSetToNull_Then_NoException() handler.SetCommandCts(cts); handler.SetCommandCts(cts: null); // Should not throw. } + + [TestMethod] + [Description("First Ctrl+C writes the double-tap hint to ReplSessionIO.Error so protocol/session error routing remains consistent.")] + public void When_FirstCancelPressDuringCommand_Then_HintUsesSessionErrorWriter() + { + var previousError = Console.Error; + using var consoleError = new StringWriter(); + Console.SetError(consoleError); + try + { + using var sessionOutput = new StringWriter(); + using var sessionError = new StringWriter(); + using var sessionScope = ReplSessionIO.SetSession( + sessionOutput, + TextReader.Null, + error: sessionError, + commandOutput: sessionOutput, + isHostedSession: false); + using var handler = new CancelKeyHandler(); + using var cts = new CancellationTokenSource(); + handler.SetCommandCts(cts); + + var method = typeof(CancelKeyHandler).GetMethod( + "OnCancelKeyPress", + BindingFlags.Instance | BindingFlags.NonPublic); + method.Should().NotBeNull(); + var args = (ConsoleCancelEventArgs?)Activator.CreateInstance( + typeof(ConsoleCancelEventArgs), + BindingFlags.Instance | BindingFlags.NonPublic, + binder: null, + args: [ConsoleSpecialKey.ControlC], + culture: null); + args.Should().NotBeNull(); + method!.Invoke(handler, [null, args]); + + cts.IsCancellationRequested.Should().BeTrue(); + args!.Cancel.Should().BeTrue(); + sessionError.ToString().Should().Contain("Press Ctrl+C again to exit."); + consoleError.ToString().Should().BeEmpty(); + } + finally + { + Console.SetError(previousError); + } + } } From daa9f14929d82d098f7097464cdad187173af5bc Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 28 Feb 2026 16:52:02 -0500 Subject: [PATCH 6/7] Handle ambiguous prefixes before banner pre-resolve --- src/Repl.Core/CoreReplApp.cs | 53 +++++++++++++------ .../Given_ProtocolPassthrough.cs | 18 +++++++ 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/src/Repl.Core/CoreReplApp.cs b/src/Repl.Core/CoreReplApp.cs index eea723e..1ba40cd 100644 --- a/src/Repl.Core/CoreReplApp.cs +++ b/src/Repl.Core/CoreReplApp.cs @@ -407,29 +407,27 @@ private async ValueTask ExecuteCoreAsync( using var runtimeStateScope = PushRuntimeState(serviceProvider, isInteractiveSession: false); var prefixResolution = ResolveUniquePrefixes(globalOptions.RemainingTokens); var resolvedGlobalOptions = globalOptions with { RemainingTokens = prefixResolution.Tokens }; + var ambiguousExitCode = await TryHandleAmbiguousPrefixAsync( + prefixResolution, + globalOptions, + resolvedGlobalOptions, + serviceProvider, + cancellationToken) + .ConfigureAwait(false); + if (ambiguousExitCode is not null) return ambiguousExitCode.Value; + var preResolvedRouteResolution = TryPreResolveRouteForBanner(resolvedGlobalOptions); if (!ShouldSuppressGlobalBanner(resolvedGlobalOptions, preResolvedRouteResolution?.Match)) { await TryRenderBannerAsync(resolvedGlobalOptions, serviceProvider, cancellationToken).ConfigureAwait(false); } - if (prefixResolution.IsAmbiguous) - { - var ambiguous = CreateAmbiguousPrefixResult(prefixResolution); - _ = await RenderOutputAsync(ambiguous, globalOptions.OutputFormat, cancellationToken) + var preExecutionExitCode = await TryHandlePreExecutionAsync( + resolvedGlobalOptions, + serviceProvider, + cancellationToken) .ConfigureAwait(false); - return 1; - } - - var preExecutionExitCode = await TryHandlePreExecutionAsync( - resolvedGlobalOptions, - serviceProvider, - cancellationToken) - .ConfigureAwait(false); - if (preExecutionExitCode is not null) - { - return preExecutionExitCode.Value; - } + if (preExecutionExitCode is not null) return preExecutionExitCode.Value; var resolution = preResolvedRouteResolution ?? ResolveWithDiagnostics(resolvedGlobalOptions.RemainingTokens); @@ -458,6 +456,29 @@ private async ValueTask ExecuteCoreAsync( } } + private async ValueTask TryHandleAmbiguousPrefixAsync( + PrefixResolutionResult prefixResolution, + GlobalInvocationOptions globalOptions, + GlobalInvocationOptions resolvedGlobalOptions, + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + if (!prefixResolution.IsAmbiguous) + { + return null; + } + + if (!ShouldSuppressGlobalBanner(resolvedGlobalOptions, preResolvedMatch: null)) + { + await TryRenderBannerAsync(resolvedGlobalOptions, serviceProvider, cancellationToken).ConfigureAwait(false); + } + + var ambiguous = CreateAmbiguousPrefixResult(prefixResolution); + _ = await RenderOutputAsync(ambiguous, globalOptions.OutputFormat, cancellationToken) + .ConfigureAwait(false); + return 1; + } + private static bool ShouldSuppressGlobalBanner( GlobalInvocationOptions globalOptions, RouteMatch? preResolvedMatch) diff --git a/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs b/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs index 93a47ca..aadee24 100644 --- a/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs +++ b/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs @@ -29,6 +29,24 @@ public void When_CommandIsProtocolPassthrough_Then_GlobalAndCommandBannersAreSup output.StdErr.Should().BeNullOrWhiteSpace(); } + [TestMethod] + [Description("Regression guard: verifies ambiguous prefixes still render the banner when ambiguity occurs before route execution, even with dynamic passthrough routes present.")] + public void When_AmbiguousPrefixOverlapsDynamicPassthroughRoute_Then_BannerIsNotSuppressed() + { + var sut = ReplApp.Create() + .WithDescription("Test banner"); + sut.Map("mcp {operation}", static (string operation) => Results.Exit(0)) + .AsProtocolPassthrough(); + sut.Map("mcp list", () => "list"); + sut.Map("mcp load", () => "load"); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["mcp", "l"])); + + output.ExitCode.Should().Be(1); + output.Text.Should().Contain("Test banner"); + output.Text.Should().Contain("Ambiguous command prefix 'l'."); + } + [TestMethod] [Description("Regression guard: verifies IReplIoContext output stays on stdout in CLI protocol passthrough while framework output remains redirected.")] public void When_ProtocolPassthroughHandlerUsesIoContext_Then_OutputIsWrittenToStdOut() From 19f3bd68ab77cff43d31d1192d0441772851ca52 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 28 Feb 2026 16:59:58 -0500 Subject: [PATCH 7/7] Keep CLI channel semantics in local passthrough sessions --- src/Repl.Core/CoreReplApp.cs | 2 +- .../Given_ProtocolPassthrough.cs | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/Repl.Core/CoreReplApp.cs b/src/Repl.Core/CoreReplApp.cs index 1ba40cd..150bcde 100644 --- a/src/Repl.Core/CoreReplApp.cs +++ b/src/Repl.Core/CoreReplApp.cs @@ -653,7 +653,7 @@ private int ResolveCurrentMappingModuleId() => private ReplRuntimeChannel ResolveCurrentRuntimeChannel() { - if (ReplSessionIO.IsSessionActive) + if (ReplSessionIO.IsHostedSession) { return ReplRuntimeChannel.Session; } diff --git a/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs b/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs index aadee24..8f3f4fa 100644 --- a/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs +++ b/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs @@ -293,10 +293,48 @@ public void When_HandlerRequestsIoContextInCliProtocolPassthrough_Then_IsHostedS output.StdErr.Should().NotContain("hosted"); } + [TestMethod] + [Description("Regression guard: verifies local CLI protocol passthrough keeps CLI channel semantics so CLI-only context validation still executes.")] + public void When_CliProtocolPassthroughRuns_Then_CliOnlyContextValidationIsNotBypassed() + { + var sut = ReplApp.Create(); + sut.MapModule( + new CliOnlyValidatedPassthroughModule(), + static context => context.Channel == ReplRuntimeChannel.Cli); + + var output = ConsoleCaptureHelper.CaptureStdOutAndErr( + () => sut.Run(["mcp", "start", "--no-logo"])); + + output.ExitCode.Should().Be(1); + output.StdOut.Should().BeNullOrWhiteSpace(); + output.StdErr.Should().Contain("Validation: context gate failed"); + } + private sealed class InMemoryHost(TextReader input, TextWriter output) : IReplHost { public TextReader Input { get; } = input; public TextWriter Output { get; } = output; } + + private sealed class CliOnlyValidatedPassthroughModule : IReplModule + { + public void Map(IReplMap map) + { + map.Context( + "mcp", + mcp => + { + mcp.Map( + "start", + (IReplIoContext io) => + { + io.Output.WriteLine("should-not-run"); + return Results.Exit(0); + }) + .AsProtocolPassthrough(); + }, + () => Results.Validation("context gate failed")); + } + } }