diff --git a/README.md b/README.md index eb2b11d..4413e0b 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,29 @@ 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` 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) 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 +236,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 +278,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..5e9980f --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,109 @@ +# 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) + +Not typical passthrough targets: + +- socket-first variants such as FastCGI (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 + +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 +- 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/docs/shell-completion.md b/docs/shell-completion.md index 01b61ea..385e9a0 100644 --- a/docs/shell-completion.md +++ b/docs/shell-completion.md @@ -12,6 +12,13 @@ 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/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.Core/CommandBuilder.cs b/src/Repl.Core/CommandBuilder.cs index 6e87d66..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); } /// @@ -49,6 +50,16 @@ 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 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. /// @@ -145,4 +156,39 @@ 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. + /// 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. + 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.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..150bcde 100644 --- a/src/Repl.Core/CoreReplApp.cs +++ b/src/Repl.Core/CoreReplApp.cs @@ -405,31 +405,32 @@ private async ValueTask ExecuteCoreAsync( { var globalOptions = GlobalOptionParser.Parse(args, _options.Output); using var runtimeStateScope = PushRuntimeState(serviceProvider, isInteractiveSession: false); - if (!_shellCompletionRuntime.IsBridgeInvocation(globalOptions.RemainingTokens)) - { - await TryRenderBannerAsync(globalOptions, serviceProvider, cancellationToken).ConfigureAwait(false); - } var prefixResolution = ResolveUniquePrefixes(globalOptions.RemainingTokens); - if (prefixResolution.IsAmbiguous) - { - var ambiguous = CreateAmbiguousPrefixResult(prefixResolution); - _ = await RenderOutputAsync(ambiguous, globalOptions.OutputFormat, cancellationToken) + var resolvedGlobalOptions = globalOptions with { RemainingTokens = prefixResolution.Tokens }; + var ambiguousExitCode = await TryHandleAmbiguousPrefixAsync( + prefixResolution, + globalOptions, + resolvedGlobalOptions, + serviceProvider, + cancellationToken) .ConfigureAwait(false); - return 1; - } + if (ambiguousExitCode is not null) return ambiguousExitCode.Value; - var resolvedGlobalOptions = globalOptions with { RemainingTokens = prefixResolution.Tokens }; - var preExecutionExitCode = await TryHandlePreExecutionAsync( - resolvedGlobalOptions, - serviceProvider, - cancellationToken) - .ConfigureAwait(false); - if (preExecutionExitCode is not null) + var preResolvedRouteResolution = TryPreResolveRouteForBanner(resolvedGlobalOptions); + if (!ShouldSuppressGlobalBanner(resolvedGlobalOptions, preResolvedRouteResolution?.Match)) { - return preExecutionExitCode.Value; + await TryRenderBannerAsync(resolvedGlobalOptions, serviceProvider, cancellationToken).ConfigureAwait(false); } - var resolution = ResolveWithDiagnostics(resolvedGlobalOptions.RemainingTokens); + var preExecutionExitCode = await TryHandlePreExecutionAsync( + resolvedGlobalOptions, + serviceProvider, + cancellationToken) + .ConfigureAwait(false); + if (preExecutionExitCode is not null) return preExecutionExitCode.Value; + + var resolution = preResolvedRouteResolution + ?? ResolveWithDiagnostics(resolvedGlobalOptions.RemainingTokens); var match = resolution.Match; if (match is null) { @@ -455,6 +456,51 @@ 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) + { + if (globalOptions.HelpRequested || globalOptions.RemainingTokens.Count == 0) + { + return false; + } + + 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( GlobalInvocationOptions options, IServiceProvider serviceProvider, @@ -489,13 +535,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.IsHostedSession + && !match.Route.Command.SupportsHostedProtocolPassthrough) + { + _ = 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 +575,39 @@ 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, + commandOutput: Console.Out, + error: Console.Error, + isHostedSession: false); + return await ExecuteMatchedCommandAsync( + match, + globalOptions, + serviceProvider, + scopeTokens: null, + cancellationToken) + .ConfigureAwait(false); + } + private async ValueTask HandleEmptyInvocationAsync( GlobalInvocationOptions globalOptions, IServiceProvider serviceProvider, @@ -552,7 +653,7 @@ private int ResolveCurrentMappingModuleId() => private ReplRuntimeChannel ResolveCurrentRuntimeChannel() { - if (ReplSessionIO.IsSessionActive) + if (ReplSessionIO.IsHostedSession) { return ReplRuntimeChannel.Session; } @@ -1183,6 +1284,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..5810aad --- /dev/null +++ b/src/Repl.Core/IReplIoContext.cs @@ -0,0 +1,33 @@ +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 real hosted transport session. + /// This is false for local CLI execution, including protocol passthrough scopes. + /// + 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..cc681a3 --- /dev/null +++ b/src/Repl.Core/LiveReplIoContext.cs @@ -0,0 +1,17 @@ +namespace Repl; + +/// +/// Live view backed by the current async-local state. +/// +internal sealed class LiveReplIoContext : IReplIoContext +{ + public TextReader Input => ReplSessionIO.Input; + + public TextWriter Output => ReplSessionIO.CommandOutput; + + public TextWriter Error => ReplSessionIO.Error; + + 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 6aff832..1aacf84 100644 --- a/src/Repl.Core/ReplSessionIO.cs +++ b/src/Repl.Core/ReplSessionIO.cs @@ -22,8 +22,11 @@ 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_isHostedSession = new(); private static readonly AsyncLocal s_sessionId = new(); private static readonly ConcurrentDictionary s_sessions = new(StringComparer.Ordinal); @@ -32,6 +35,20 @@ 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. + /// + /// + /// Falls back to , which itself falls back to . + /// + public static TextWriter CommandOutput => s_commandOutput.Value ?? Output; + /// /// Gets the current session input reader, or when no session is active. /// @@ -42,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. /// @@ -180,14 +202,20 @@ public static IDisposable SetSession( TextWriter output, TextReader input, AnsiMode ansiMode = AnsiMode.Auto, - string? sessionId = null) + string? sessionId = null, + TextWriter? commandOutput = null, + TextWriter? error = null, + bool isHostedSession = true) { 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 previousIsHostedSession = s_isHostedSession.Value; var previousSessionId = s_sessionId.Value; var resolvedSessionId = string.IsNullOrWhiteSpace(sessionId) @@ -196,7 +224,11 @@ 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; + s_isHostedSession.Value = isHostedSession; s_sessionId.Value = resolvedSessionId; if (ansiMode == AnsiMode.Always) @@ -222,8 +254,11 @@ public static IDisposable SetSession( return new SessionScope( previousOutput, + previousError, + previousCommandOutput, previousInput, previousKeyReader, + previousIsHostedSession, previousSessionId, removeSessionOnDispose: string.IsNullOrWhiteSpace(sessionId), sessionIdToRemove: resolvedSessionId); @@ -293,8 +328,11 @@ private static SessionMetadata NormalizeSession(string sessionId, SessionMetadat private sealed class SessionScope( TextWriter? previousOutput, + TextWriter? previousError, + TextWriter? previousCommandOutput, TextReader? previousInput, IReplKeyReader? previousKeyReader, + bool previousIsHostedSession, string? previousSessionId, bool removeSessionOnDispose, string sessionIdToRemove) : IDisposable @@ -302,8 +340,11 @@ 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_isHostedSession.Value = previousIsHostedSession; s_sessionId.Value = previousSessionId; if (removeSessionOnDispose) 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 3524e59..beb3d5f 100644 --- a/src/Repl.Core/ShellCompletion/ShellCompletionModule.cs +++ b/src/Repl.Core/ShellCompletion/ShellCompletionModule.cs @@ -14,8 +14,24 @@ 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) + { + // Error/validation results are rendered by the framework (stderr in passthrough mode). + 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(); completion.Map( "install", 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.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..b2357f5 100644 --- a/src/Repl.IntegrationTests/ConsoleCaptureHelper.cs +++ b/src/Repl.IntegrationTests/ConsoleCaptureHelper.cs @@ -2,22 +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); + s_consoleLock.Wait(); + + try + { + 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 + { + s_consoleLock.Release(); + } + } - var previous = Console.Out; - using var writer = new StringWriter(); - Console.SetOut(writer); + public static (int ExitCode, string StdOut, string StdErr) CaptureStdOutAndErr(Func action) + { + ArgumentNullException.ThrowIfNull(action); + s_consoleLock.Wait(); try { - var exitCode = action(); - return (exitCode, writer.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(previous); + s_consoleLock.Release(); } } @@ -25,42 +66,96 @@ public static (int ExitCode, string Text) CaptureWithInput(string input, Func action) + { + ArgumentNullException.ThrowIfNull(input); + ArgumentNullException.ThrowIfNull(action); + s_consoleLock.Wait(); try { - var exitCode = action(); - return (exitCode, writer.ToString()); + 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); + } } finally { - Console.SetOut(previousOut); - Console.SetIn(previousIn); + s_consoleLock.Release(); } } public static async Task<(int ExitCode, string Text)> 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 new file mode 100644 index 0000000..8f3f4fa --- /dev/null +++ b/src/Repl.IntegrationTests/Given_ProtocolPassthrough.cs @@ -0,0 +1,340 @@ +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 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() + { + 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() + { + 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 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() + { + 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() + { + 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 --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() + { + 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 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() + { + 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"); + } + + [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"); + } + + [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")); + } + } +} 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); + } + } } 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(); + } +}