diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 42db7043..37a02ed9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -57,7 +57,8 @@ - Be careful with state transitions in the shell. Some states share the same `CosmosClient`, so disposing the old state during navigation can break the new state. - Be careful with iterator lifetimes when returning `IAsyncEnumerable`; do not dispose iterators before enumeration completes. -- MCP in this repo is HTTP-only. The `--mcp` option optionally accepts a port and defaults to `6128`. +- MCP supports two transports. `--mcp [port]` starts the HTTP server (default port `6128`). `--mcp-stdio` runs a headless server where stdin/stdout carry only MCP JSON-RPC and diagnostics go to stderr; it cannot be combined with `--mcp`, `--lsp`, `--stdio`, `-c`, `-k`, or `--clear-history`. +- In MCP stdio mode, nothing may write to the protocol stdout. Route user-facing output through `ShellOutput` (enforced by analyzer rule CZ0003), and never read console input. ## Preferred Change Pattern diff --git a/CHANGELOG.md b/CHANGELOG.md index ae499519..c9c7d40a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ ## Unreleased +### Compatibility + +- Machine-mode `watch` now requires a positive `--max` so changes are returned as a bounded final result instead of silently suppressed. Interactive watches can still follow until Ctrl+C. + +### New features + +- MCP stdio mode no longer loads or records shell history; existing history files are left untouched. Explicit diagnostic logging remains available. +- Add `--mcp-stdio` for client-owned, headless MCP processes with protocol-only stdin/stdout, stderr diagnostics, no HTTP listener, and shutdown on stdin EOF. Startup connection/navigation, MCP confirmations, and location subscriptions are supported. Unsupported commands, including `jq`, `theme`, and interactive-only commands, are omitted from tools and help; destructive commands remain available through confirmation. +- MCP stdio mode never opens a browser for sign-in. `--tenant`/`--hint` connections use device code sign-in with instructions on stderr, and endpoint-only connections use `DefaultAzureCredential` without its interactive browser step. Cancelling the tool call ends a pending sign-in. + +### Improvements + +- Cap MCP stdio startup input buffering at 8 MiB and report an explicit error when the limit is exceeded. +- Bound MCP stdio startup connection and navigation to 60 seconds and cancel pending startup when stdin closes. Report startup timeout errors on stderr with exit code 4. MCP request cancellation no longer logs an execution failure. +- Centralize shell presentation by message category. `--quiet` suppresses informational messages, progress, banners, and command echoes while preserving results, warnings, errors, and required authentication instructions, including previews shown before a confirmation. Machine-mode diagnostics use stderr without ANSI styling, and machine-mode stdout carries only the command result. MCP stdio continues to return results only through the protocol. Explicit diagnostic logging is unchanged. +- In machine mode, `theme`, `help`, and `edit` failures are now reported as structured `{ "status": "error", ... }` objects on stderr; previously they exited with code 1 and no message. Interactive and script errors from these commands are reported once. +- A new build analyzer rule (CZ0003) rejects direct `Console`/`AnsiConsole` output outside the shell's output policy. ### Fixes - Parse `exec` options and shell words like direct commands. Bind built-in options normally and pass option-shaped words to functions and script files as positional text. diff --git a/CosmosDBShell.Analyzer/DirectConsoleOutputAnalyzer.cs b/CosmosDBShell.Analyzer/DirectConsoleOutputAnalyzer.cs new file mode 100644 index 00000000..78d0575e --- /dev/null +++ b/CosmosDBShell.Analyzer/DirectConsoleOutputAnalyzer.cs @@ -0,0 +1,107 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using System.Collections.Immutable; + +namespace CosmosShell.Analyzer +{ + /// + /// Requires user-facing console output to go through ShellOutput, which applies the + /// quiet, machine-mode, and MCP stdio presentation policies. Direct writes bypass those + /// policies and can corrupt structured stdout or the MCP protocol stream. + /// + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class DirectConsoleOutputAnalyzer : DiagnosticAnalyzer + { + public const string DiagnosticId = "CZ0003"; + + private const string Category = "Usage"; + private const string AllowedType = "Azure.Data.Cosmos.Shell.Core.ShellOutput"; + + private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor( + DiagnosticId, + "Direct console output", + "'{0}' bypasses the shell output policy; write through ShellOutput instead", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "User-facing output must go through ShellOutput so --quiet, machine mode, and MCP stdio are honored."); + + public override ImmutableArray SupportedDiagnostics { get { return ImmutableArray.Create(Rule); } } + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.InvocationExpression); + } + + private static void Analyze(SyntaxNodeAnalysisContext context) + { + var invocation = (InvocationExpressionSyntax)context.Node; + if (!(context.SemanticModel.GetSymbolInfo(invocation, context.CancellationToken).Symbol is IMethodSymbol method)) + { + return; + } + + if (!IsDirectOutput(context.SemanticModel, invocation, method, context)) + { + return; + } + + var containingType = context.ContainingSymbol?.ContainingType; + while (containingType != null) + { + if (containingType.ToDisplayString() == AllowedType) + { + return; + } + + containingType = containingType.ContainingType; + } + + context.ReportDiagnostic(Diagnostic.Create(Rule, invocation.GetLocation(), method.ContainingType.Name + "." + method.Name)); + } + + private static bool IsDirectOutput(SemanticModel model, InvocationExpressionSyntax invocation, IMethodSymbol method, SyntaxNodeAnalysisContext context) + { + var type = method.ContainingType?.ToDisplayString(); + + // Console.Write / Console.WriteLine. + if (method.IsStatic && type == "System.Console" && method.Name.StartsWith("Write")) + { + return true; + } + + // Every static AnsiConsole call except Create renders to the global console. + if (method.IsStatic && type == "Spectre.Console.AnsiConsole" && method.Name != "Create") + { + return true; + } + + // Console.Out.Write*, Console.Error.Write*, and AnsiConsole.Console.. + if (invocation.Expression is MemberAccessExpressionSyntax access + && model.GetSymbolInfo(access.Expression, context.CancellationToken).Symbol is IPropertySymbol receiver + && receiver.IsStatic) + { + var owner = receiver.ContainingType?.ToDisplayString(); + if (owner == "System.Console" && (receiver.Name == "Out" || receiver.Name == "Error") && method.Name.StartsWith("Write")) + { + return true; + } + + if (owner == "Spectre.Console.AnsiConsole" && receiver.Name == "Console") + { + return true; + } + } + + return false; + } + } +} diff --git a/CosmosDBShell.Analyzer/GlobalSuppressions.cs b/CosmosDBShell.Analyzer/GlobalSuppressions.cs index f6ec9e97..db9f4d45 100644 --- a/CosmosDBShell.Analyzer/GlobalSuppressions.cs +++ b/CosmosDBShell.Analyzer/GlobalSuppressions.cs @@ -11,3 +11,4 @@ [assembly: SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "", Scope = "member", Target = "~F:CosmosShell.Analyzer.EmptyCatchClauseAnalyzer.Rule")] [assembly: SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "", Scope = "member", Target = "~F:CosmosShell.Analyzer.WriteLineAnalyzer.Rule")] +[assembly: SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "", Scope = "member", Target = "~F:CosmosShell.Analyzer.DirectConsoleOutputAnalyzer.Rule")] diff --git a/CosmosDBShell.Tests/CommandTests/BucketCommandTests.cs b/CosmosDBShell.Tests/CommandTests/BucketCommandTests.cs index 97f068ed..d32f97b0 100644 --- a/CosmosDBShell.Tests/CommandTests/BucketCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/BucketCommandTests.cs @@ -99,16 +99,16 @@ public async Task ExecuteAsync_InContainer_NoArgs_ShowsCurrent() } [Fact] - public async Task ExecuteAsync_InDatabase_InvalidValue_ReturnsEmptyState() + public async Task ExecuteAsync_InDatabase_InvalidValue_ThrowsCommandException() { var client = CreateTestClient(); using var shell = ShellInterpreter.CreateInstance(); shell.State = new DatabaseState("TestDatabase", client); var command = new BucketCommand { Action = "99" }; - var state = await command.ExecuteAsync(shell, new CommandState(), "bucket 99", CancellationToken.None); + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "bucket 99", CancellationToken.None)); - Assert.False(state.IsError); Assert.Null(client.ClientOptions.ThroughputBucket); } diff --git a/CosmosDBShell.Tests/CommandTests/ConnectCommandTests.cs b/CosmosDBShell.Tests/CommandTests/ConnectCommandTests.cs index af70823c..6e64f150 100644 --- a/CosmosDBShell.Tests/CommandTests/ConnectCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/ConnectCommandTests.cs @@ -36,6 +36,77 @@ public void CreateClientOptions_Emulator_UsesShortRequestTimeout() Assert.Equal(TimeSpan.FromSeconds(5), options.RequestTimeout); } + [Theory] + [InlineData(false, true)] + [InlineData(true, false)] + public void AllowsInteractiveBrowser_IsDisabledOnlyForMcpStdio(bool mcpStdio, bool expected) + { + Assert.Equal(expected, ShellInterpreter.AllowsInteractiveBrowser(mcpStdio)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CreateDefaultCredentialOptions_ExcludesBrowserWhenNotAllowed(bool allowInteractiveBrowser) + { + var authority = new Uri("https://login.example.test/"); + + var options = ShellInterpreter.CreateDefaultCredentialOptions(authority, allowInteractiveBrowser); + + Assert.Equal(!allowInteractiveBrowser, options.ExcludeInteractiveBrowserCredential); + Assert.Equal(!allowInteractiveBrowser, options.ExcludeBrokerCredential); + Assert.Equal(authority, options.AuthorityHost); + Assert.False(options.ExcludeAzureCliCredential); + Assert.False(options.ExcludeManagedIdentityCredential); + } + + [Fact] + public void IsMcpStdio_FollowsOptions() + { + using var shell = ShellInterpreter.CreateInstance(); + Assert.False(shell.IsMcpStdio); + + shell.Options = new Program.CosmosShellOptions { McpStdio = true }; + Assert.True(shell.IsMcpStdio); + } + + [Fact] + public async Task ConnectAsync_McpStdioWithTenant_UsesDeviceCodeWithoutBrowser() + { + var shell = ShellInterpreter.Instance; + var originalOptions = shell.Options; + var originalError = Console.Error; + var originalOut = Console.Out; + using var error = new StringWriter(); + using var output = new StringWriter(); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + try + { + shell.Options = new Program.CosmosShellOptions { McpStdio = true }; + Console.SetError(error); + Console.SetOut(output); + timeout.CancelAfter(TimeSpan.FromSeconds(2)); + + // Device code polling cannot complete here: the attempt ends by cancellation or, + // without network access, by an authentication failure. Neither may open a browser. + await Assert.ThrowsAnyAsync(() => shell.ConnectAsync( + "https://stdio-auth-test.documents.azure.com:443/", + tenantId: "00000000-0000-0000-0000-000000000000", + token: timeout.Token)); + } + finally + { + Console.SetError(originalError); + Console.SetOut(originalOut); + shell.Options = originalOptions; + } + + var stderr = error.ToString(); + Assert.Contains(MessageService.GetString("shell-connect-devicecode-stdio"), stderr, StringComparison.Ordinal); + Assert.DoesNotContain(MessageService.GetString("shell-connect-browser-auth"), stderr + output, StringComparison.Ordinal); + Assert.IsNotType(shell.State); + } + [Fact] public void ConnectivityFailure_LocalEmulator_DisconnectsShell() { diff --git a/CosmosDBShell.Tests/CommandTests/ThemeCommandDispatchTests.cs b/CosmosDBShell.Tests/CommandTests/ThemeCommandDispatchTests.cs index 4c9bd737..a35614f9 100644 --- a/CosmosDBShell.Tests/CommandTests/ThemeCommandDispatchTests.cs +++ b/CosmosDBShell.Tests/CommandTests/ThemeCommandDispatchTests.cs @@ -123,9 +123,7 @@ public async Task Show_KnownTheme_PreviewsWithoutChangingActive(string name) [Fact] public async Task Show_UnknownTheme_ReturnsError() { - var state = await RunAsync(new ThemeCommand { Action = "show", Name = "does-not-exist-xyz" }); - - Assert.IsType(state); + await Assert.ThrowsAsync(() => RunAsync(new ThemeCommand { Action = "show", Name = "does-not-exist-xyz" })); } [Fact] @@ -167,25 +165,19 @@ public async Task Set_AliasOfUse_AppliesTheme() [Fact] public async Task Use_MissingName_ReturnsError() { - var state = await RunAsync(new ThemeCommand { Action = "use" }); - - Assert.IsType(state); + await Assert.ThrowsAsync(() => RunAsync(new ThemeCommand { Action = "use" })); } [Fact] public async Task Use_UnknownName_ReturnsError() { - var state = await RunAsync(new ThemeCommand { Action = "use", Name = "nope-xyz" }); - - Assert.IsType(state); + await Assert.ThrowsAsync(() => RunAsync(new ThemeCommand { Action = "use", Name = "nope-xyz" })); } [Fact] public async Task Load_MissingPath_ReturnsError() { - var state = await RunAsync(new ThemeCommand { Action = "load" }); - - Assert.IsType(state); + await Assert.ThrowsAsync(() => RunAsync(new ThemeCommand { Action = "load" })); } [Fact] @@ -214,9 +206,7 @@ public async Task Load_NonExistentFile_ReturnsError() { var path = Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.toml"); - var state = await RunAsync(new ThemeCommand { Action = "load", Name = path }); - - Assert.IsType(state); + await Assert.ThrowsAsync(() => RunAsync(new ThemeCommand { Action = "load", Name = path })); } [Fact] @@ -225,9 +215,7 @@ public async Task Load_InvalidToml_ReturnsError() var path = WriteTempToml($"bad-{Guid.NewGuid():N}", "this is = = not valid toml [[["); try { - var state = await RunAsync(new ThemeCommand { Action = "load", Name = path }); - - Assert.IsType(state); + await Assert.ThrowsAsync(() => RunAsync(new ThemeCommand { Action = "load", Name = path })); } finally { @@ -238,17 +226,13 @@ public async Task Load_InvalidToml_ReturnsError() [Fact] public async Task Save_MissingName_ReturnsError() { - var state = await RunAsync(new ThemeCommand { Action = "save" }); - - Assert.IsType(state); + await Assert.ThrowsAsync(() => RunAsync(new ThemeCommand { Action = "save" })); } [Fact] public async Task Save_InvalidName_ReturnsError() { - var state = await RunAsync(new ThemeCommand { Action = "save", Name = "../escape" }); - - Assert.IsType(state); + await Assert.ThrowsAsync(() => RunAsync(new ThemeCommand { Action = "save", Name = "../escape" })); } [Fact] @@ -280,9 +264,7 @@ public async Task Save_ExistingFileWithoutForce_ReturnsError() File.WriteAllText(path, "name = \"existing\""); try { - var state = await RunAsync(new ThemeCommand { Action = "save", Name = "exported", Path = path }); - - Assert.IsType(state); + await Assert.ThrowsAsync(() => RunAsync(new ThemeCommand { Action = "save", Name = "exported", Path = path })); } finally { @@ -321,9 +303,7 @@ public async Task Reload_RescansUserThemesDirectory() [Fact] public async Task UnknownAction_ReturnsError() { - var state = await RunAsync(new ThemeCommand { Action = "frobnicate" }); - - Assert.IsType(state); + await Assert.ThrowsAsync(() => RunAsync(new ThemeCommand { Action = "frobnicate" })); } private static Task RunAsync(ThemeCommand command) => diff --git a/CosmosDBShell.Tests/CommandTests/ThemeCommandTests.cs b/CosmosDBShell.Tests/CommandTests/ThemeCommandTests.cs index ec163e6f..a65e3d4a 100644 --- a/CosmosDBShell.Tests/CommandTests/ThemeCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/ThemeCommandTests.cs @@ -81,10 +81,9 @@ public async Task Validate_ScansDirectoryAndReportsCounts() Name = dir.Path, }; - var state = await command.ExecuteAsync(ShellInterpreter.Instance, new CommandState(), "", CancellationToken.None); - var error = Assert.IsType(state); + var error = await Assert.ThrowsAsync(() => command.ExecuteAsync(ShellInterpreter.Instance, new CommandState(), "", CancellationToken.None)); - Assert.Contains("1 of 2", error.Exception.Message); + Assert.Contains("1 of 2", error.Message); } [Fact] @@ -109,9 +108,8 @@ public async Task Validate_StrictTreatsWarningsAsErrors() Strict = true, }; - var state = await command.ExecuteAsync(ShellInterpreter.Instance, new CommandState(), "", CancellationToken.None); - var error = Assert.IsType(state); - Assert.Contains("strict mode", error.Exception.Message); + var error = await Assert.ThrowsAsync(() => command.ExecuteAsync(ShellInterpreter.Instance, new CommandState(), "", CancellationToken.None)); + Assert.Contains("strict mode", error.Message); } finally { diff --git a/CosmosDBShell.Tests/CommandTests/WatchCommandTests.cs b/CosmosDBShell.Tests/CommandTests/WatchCommandTests.cs index d890fa23..4f92451c 100644 --- a/CosmosDBShell.Tests/CommandTests/WatchCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/WatchCommandTests.cs @@ -118,4 +118,59 @@ public async Task ExecuteAsync_Disconnected_ThrowsNotConnected() await Assert.ThrowsAsync( () => command.ExecuteAsync(shell, new CommandState(), "watch", TestContext.Current.CancellationToken)); } + + [Theory] + [InlineData(true, null, null)] + [InlineData(true, null, 0)] + [InlineData(true, null, -1)] + [InlineData(false, "json", null)] + [InlineData(false, "json", 0)] + [InlineData(false, "csv", -1)] + public async Task ExecuteAsync_UnboundedMachineWatch_RejectsBeforeConnection(bool quiet, string? output, int? max) + { + using var shell = ShellInterpreter.CreateInstance(); + shell.Options = new Program.CosmosShellOptions { Quiet = quiet, Output = output }; + shell.State = new DisconnectedState(); + var command = new WatchCommand { Max = max }; + + var error = await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "watch", TestContext.Current.CancellationToken)); + + Assert.Contains("positive --max", error.Message); + } + + [Theory] + [InlineData(null)] + [InlineData("changes.json")] + public async Task ExecuteAsync_UnboundedExecuteAndQuitWatch_RejectsIncludingRedirection(string? redirectedPath) + { + using var shell = ShellInterpreter.CreateInstance(); + shell.Options = new Program.CosmosShellOptions { ExecuteAndQuit = "watch" }; + shell.StdOutRedirect = redirectedPath; + shell.State = new DisconnectedState(); + var command = new WatchCommand(); + + var error = await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "watch", TestContext.Current.CancellationToken)); + + Assert.Contains("positive --max", error.Message); + } + + [Theory] + [InlineData(true, null, 1)] + [InlineData(false, "json", 100)] + [InlineData(false, "csv", 1)] + [InlineData(false, "user", null)] + [InlineData(false, "table", 0)] + [InlineData(false, null, -1)] + public async Task ExecuteAsync_BoundedMachineOrInteractiveWatch_PreservesConnectionGuard(bool quiet, string? output, int? max) + { + using var shell = ShellInterpreter.CreateInstance(); + shell.Options = new Program.CosmosShellOptions { Quiet = quiet, Output = output }; + shell.State = new DisconnectedState(); + var command = new WatchCommand { Max = max }; + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "watch", TestContext.Current.CancellationToken)); + } } diff --git a/CosmosDBShell.Tests/Integration/AdditionalCommandsTests.cs b/CosmosDBShell.Tests/Integration/AdditionalCommandsTests.cs index 24097fe0..63d37829 100644 --- a/CosmosDBShell.Tests/Integration/AdditionalCommandsTests.cs +++ b/CosmosDBShell.Tests/Integration/AdditionalCommandsTests.cs @@ -71,12 +71,10 @@ public async Task Bucket_ShowsCurrent_SetsAndResets() } [Fact] - public async Task Bucket_InvalidValue_DoesNotErrorButDoesNotSet() + public async Task Bucket_InvalidValue_ReturnsError() { - // CheckBucket prints a message for out-of-range values but returns an empty state - // rather than throwing. We still ensure it does not crash the shell. var state = await ExecuteAsync("bucket 99"); - Assert.False(state.IsError, IntegrationTestBase.FormatError(state)); + Assert.True(state.IsError); } [Fact] diff --git a/CosmosDBShell.Tests/Integration/McpStdioProcessTests.cs b/CosmosDBShell.Tests/Integration/McpStdioProcessTests.cs new file mode 100644 index 00000000..3f9dfec3 --- /dev/null +++ b/CosmosDBShell.Tests/Integration/McpStdioProcessTests.cs @@ -0,0 +1,626 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.Integration; + +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Channels; +using ModelContextProtocol.Protocol; + +public class McpStdioProcessTests +{ + [Fact] + public async Task StdioQuiet_PreservesWarningsAndProtocolResults() + { + await using var server = new ServerProcess(["--quiet", "--theme", "STDIO_WARNING_THEME"]); + await server.InitializeAsync("2025-11-25"); + var echo = await server.CallToolAsync(2, "echo", new { messages = new[] { "QUIET_STDIO_RESULT" } }); + Assert.Equal("QUIET_STDIO_RESULT", echo.GetProperty("result").GetProperty("structuredContent").GetProperty("result").GetString()); + var error = await server.CallToolAsync(3, "help", new { command = "QUIET_MISSING_COMMAND" }); + Assert.True(error.GetProperty("result").GetProperty("isError").GetBoolean()); + await server.CompleteAsync(); + Assert.Equal(0, server.ExitCode); + Assert.Contains("STDIO_WARNING_THEME", server.StdErr); + Assert.DoesNotContain("QUIET_STDIO_RESULT", server.StdErr); + Assert.DoesNotContain("\u001b", server.StdErr, StringComparison.Ordinal); + } + + [Theory] + [InlineData("--help", "USAGE:")] + [InlineData("--version", "CosmosDBShell")] + public async Task Stdio_ExplicitHelpAndVersion_UseStderr(string argument, string expected) + { + await using var server = new ServerProcess([argument]); + await server.CompleteAsync(); + Assert.Equal(0, server.ExitCode); + Assert.Equal(0, server.MessageCount); + Assert.Contains(expected, server.StdErr); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Stdio_DoesNotCreateOrModifyHistory(bool seedHistory) + { + await using var server = new ServerProcess(seedHistory: seedHistory); + var historyFile = Path.Join(server.ConfigDirectory, "cmd_history"); + var original = seedHistory ? await File.ReadAllBytesAsync(historyFile, TestContext.Current.CancellationToken) : null; + await server.InitializeAsync("2025-11-25"); + await server.CallToolAsync(2, "echo", new { messages = new[] { "NO_HISTORY_MARKER" } }); + await server.CompleteAsync(); + Assert.Equal(0, server.ExitCode); + Assert.False(File.Exists(historyFile + ".lock")); + if (original is not null) + { + Assert.Equal(original, await File.ReadAllBytesAsync(historyFile, TestContext.Current.CancellationToken)); + } + else + { + Assert.False(File.Exists(historyFile)); + } + } + + [Theory] + [InlineData("2025-11-25")] + [InlineData("2026-07-28")] + public async Task Stdio_ToolsResourcesAndDiagnostics_KeepStdoutProtocolOnly(string version) + { + await using var server = new ServerProcess(); + await server.InitializeAsync(version); + + var tools = await server.RequestAsync(2, "tools/list", new { }); + var toolNames = tools.GetProperty("result").GetProperty("tools").EnumerateArray() + .Select(tool => tool.GetProperty("name").GetString()) + .ToList(); + Assert.Contains("echo", toolNames); + Assert.Contains("help", toolNames); + Assert.DoesNotContain("jq", toolNames); + Assert.DoesNotContain("theme", toolNames); + + var echo = await server.CallToolAsync(3, "echo", new { messages = new[] { "STDIO_RESULT" } }); + Assert.Equal("STDIO_RESULT", echo.GetProperty("result").GetProperty("structuredContent").GetProperty("result").GetString()); + + var helpList = await server.CallToolAsync(4, "help", new { }); + var helpCommands = helpList.GetProperty("result").GetProperty("structuredContent").GetProperty("result") + .GetProperty("commands").EnumerateArray() + .Select(command => command.GetProperty("command").GetString()) + .ToList(); + Assert.Contains("echo", helpCommands); + Assert.DoesNotContain("jq", helpCommands); + Assert.DoesNotContain("theme", helpCommands); + foreach (var name in new[] { "exit", "edit", "watch", "welcome", "sproc", "udf", "trigger" }) + { + Assert.DoesNotContain(name, toolNames); + Assert.DoesNotContain(name, helpCommands); + } + + foreach (var name in new[] { "delete", "rm", "rmdb", "rmcon" }) + { + Assert.Contains(name, toolNames); + Assert.Contains(name, helpCommands); + } + + Assert.Equal(toolNames.Order(), helpCommands.Order()); + + var help = await server.CallToolAsync(5, "help", new { command = "STDIO_MISSING_COMMAND" }); + Assert.True(help.GetProperty("result").GetProperty("isError").GetBoolean()); + + var unknown = await server.CallToolAsync(6, "STDIO_UNKNOWN_TOOL", new { }); + Assert.True(unknown.GetProperty("result").GetProperty("isError").GetBoolean()); + + var resource = await server.RequestAsync(7, "resources/read", new { uri = "cosmos://shell/current-location" }); + var content = resource.GetProperty("result").GetProperty("contents")[0].GetProperty("text").GetString()!; + using var location = JsonDocument.Parse(content); + Assert.Equal(JsonValueKind.Null, location.RootElement.GetProperty("currentLocation").ValueKind); + + var status = await server.CallToolAsync(8, "version", new { }); + var statusResult = status.GetProperty("result").GetProperty("structuredContent").GetProperty("result"); + Assert.True(statusResult.GetProperty("mcpEnabled").GetBoolean()); + Assert.Equal(JsonValueKind.Null, statusResult.GetProperty("mcpPort").ValueKind); + + await server.CompleteAsync(); + Assert.Equal(0, server.ExitCode); + Assert.Contains("STDIO_MISSING_COMMAND", server.StdErr); + Assert.DoesNotContain("STDIO_RESULT", server.StdErr); + } + + [Theory] + [InlineData("2025-06-18")] + [InlineData("2025-11-25")] + public async Task Stdio_LocationSubscription_RoundTripsAndEndsOnEof(string version) + { + await using var server = new ServerProcess(); + await server.InitializeAsync(version); + var subscription = await server.RequestAsync(2, "resources/subscribe", new { uri = "cosmos://shell/current-location" }); + Assert.True(subscription.TryGetProperty("result", out _), subscription.ToString()); + var invalid = await server.RequestAsync(3, "resources/subscribe", new { uri = "cosmos://docs/scripting" }); + Assert.Equal(-32602, invalid.GetProperty("error").GetProperty("code").GetInt32()); + var unsubscribe = await server.RequestAsync(4, "resources/unsubscribe", new { uri = "cosmos://shell/current-location" }); + Assert.True(unsubscribe.TryGetProperty("result", out _), unsubscribe.ToString()); + var resubscribe = await server.RequestAsync(5, "resources/subscribe", new { uri = "cosmos://shell/current-location" }); + Assert.True(resubscribe.TryGetProperty("result", out _), resubscribe.ToString()); + await server.CompleteAsync(); + Assert.Equal(0, server.ExitCode); + } + + [Fact] + public async Task Stdio_LatestSubscriptionListen_AcknowledgesAndEndsOnEof() + { + await using var server = new ServerProcess(); + await server.InitializeAsync("2026-07-28"); + await server.WriteRequestAsync(2, "subscriptions/listen", new + { + notifications = new { resourceSubscriptions = new[] { "cosmos://shell/current-location" } }, + }); + var acknowledgement = await server.ReadMessageAsync(); + Assert.Equal(NotificationMethods.SubscriptionsAcknowledgedNotification, acknowledgement.GetProperty("method").GetString()); + Assert.Equal("cosmos://shell/current-location", + acknowledgement.GetProperty("params").GetProperty("notifications").GetProperty("resourceSubscriptions")[0].GetString()); + await server.CompleteAsync(); + Assert.Equal(0, server.ExitCode); + } + + [Theory] + [InlineData("decline")] + [InlineData("cancel")] + [InlineData("accept")] + public async Task Stdio_DestructiveConfirmation_UsesProtocolEvenWithForce(string action) + { + await using var server = new ServerProcess(); + await server.InitializeAsync("2025-11-25", new { elicitation = new { form = new { } } }); + await server.WriteAsync(new + { + jsonrpc = "2.0", + id = 2, + method = "tools/call", + @params = new { name = "rmdb", arguments = new { name = "StdioConfirmationDb", force = true } }, + }); + var prompt = await server.ReadMessageAsync(); + Assert.Equal("elicitation/create", prompt.GetProperty("method").GetString()); + Assert.Contains("StdioConfirmationDb", prompt.GetProperty("params").GetProperty("message").GetString()); + await server.WriteAsync(new + { + jsonrpc = "2.0", + id = prompt.GetProperty("id"), + result = new { action, content = new { } }, + }); + var response = await server.ReadResponseAsync(2); + Assert.True(response.GetProperty("result").GetProperty("isError").GetBoolean()); + var error = response.GetProperty("result").GetProperty("structuredContent").GetProperty("error").GetString(); + if (action == "accept") + { + Assert.DoesNotContain("was not approved", error); + Assert.DoesNotContain("does not support", error); + } + else + { + Assert.Contains("was not approved", error); + } + + await server.CompleteAsync(); + Assert.Equal(0, server.ExitCode); + } + + [Fact] + public async Task Stdio_ClientWithoutElicitation_RefusesDestructiveTool() + { + await using var server = new ServerProcess(); + await server.InitializeAsync("2025-11-25"); + var response = await server.CallToolAsync(2, "rmdb", new { name = "StdioConfirmationDb", force = true }); + Assert.True(response.GetProperty("result").GetProperty("isError").GetBoolean()); + Assert.Contains("does not support confirmation", response.GetProperty("result").GetProperty("structuredContent").GetProperty("error").GetString()); + await server.CompleteAsync(); + Assert.Equal(0, server.ExitCode); + } + + [Fact] + public async Task Stdio_EofWhileAwaitingConfirmation_ExitsCleanly() + { + await using var server = new ServerProcess(); + await server.InitializeAsync("2025-11-25", new { elicitation = new { form = new { } } }); + await server.WriteRequestAsync(2, "tools/call", new + { + name = "rmdb", + arguments = new { name = "StdioConfirmationDb" }, + }); + var prompt = await server.ReadMessageAsync(); + Assert.Equal("elicitation/create", prompt.GetProperty("method").GetString()); + await server.CompleteAsync(); + Assert.Equal(0, server.ExitCode); + } + + [Fact] + public async Task Stdio_CancelPendingConfirmation_RemainsResponsive() + { + await using var server = new ServerProcess(); + await server.InitializeAsync("2025-11-25", new { elicitation = new { form = new { } } }); + await server.WriteRequestAsync(2, "tools/call", new + { + name = "rmdb", + arguments = new { name = "CancelledStdioDb" }, + }); + var prompt = await server.ReadMessageAsync(); + Assert.Equal("elicitation/create", prompt.GetProperty("method").GetString()); + await server.WriteAsync(new + { + jsonrpc = "2.0", + method = "notifications/cancelled", + @params = new { requestId = 2, reason = "stdio cancellation test" }, + }); + await server.WriteRequestAsync(3, "tools/call", new + { + name = "echo", + arguments = new { messages = new[] { "AFTER_STDIO_CANCELLATION" } }, + }); + var response = await server.ReadResponseAsync(3, cancelledIds: [2]); + Assert.Equal("AFTER_STDIO_CANCELLATION", + response.GetProperty("result").GetProperty("structuredContent").GetProperty("result").GetString()); + await server.CompleteAsync(); + Assert.Equal(0, server.ExitCode); + Assert.DoesNotContain("An exception occurred", server.StdErr); + } + + [Fact] + public async Task Stdio_EofDuringStartupConnection_ExitsWithoutWaitingForNetwork() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var endpoint = (IPEndPoint)listener.LocalEndpoint; + await using var server = new ServerProcess([ + "--connect", + $"AccountEndpoint=https://127.0.0.1:{endpoint.Port}/;AccountKey=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=;", + ]); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(10)); + using var connection = await listener.AcceptTcpClientAsync(timeout.Token); + await server.CompleteAsync().WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + Assert.Equal(0, server.ExitCode); + Assert.Equal(0, server.MessageCount); + Assert.DoesNotContain("timed out", server.StdErr, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Stdio_StartupFailure_ExitsWithStdinStillOpen() + { + await using var server = new ServerProcess(["--connect", "not-an-endpoint"]); + await server.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + Assert.Equal(2, server.ExitCode); + Assert.Equal(0, server.MessageCount); + Assert.NotEmpty(server.StdErr); + } + + [Fact] + public async Task Stdio_CancelRunningAndQueuedTools_ReleasesShell() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var endpoint = (IPEndPoint)listener.LocalEndpoint; + await using var server = new ServerProcess(); + await server.InitializeAsync("2025-11-25"); + await server.WriteRequestAsync(2, "tools/call", new + { + name = "connect", + arguments = new + { + connectionString = $"AccountEndpoint=https://127.0.0.1:{endpoint.Port}/;AccountKey=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=;", + }, + }); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(10)); + using var connection = await listener.AcceptTcpClientAsync(timeout.Token); + await server.WriteRequestAsync(3, "tools/call", new + { + name = "echo", + arguments = new { messages = new[] { "CANCELLED_QUEUED_RESULT" } }, + }); + foreach (var requestId in new[] { 3, 2 }) + { + await server.WriteAsync(new + { + jsonrpc = "2.0", + method = "notifications/cancelled", + @params = new { requestId, reason = "stdio cancellation test" }, + }); + } + + await server.WriteRequestAsync(4, "tools/call", new + { + name = "echo", + arguments = new { messages = new[] { "AFTER_QUEUED_CANCELLATION" } }, + }); + var response = await server.ReadResponseAsync(4, cancelledIds: [2, 3]); + Assert.Equal("AFTER_QUEUED_CANCELLATION", + response.GetProperty("result").GetProperty("structuredContent").GetProperty("result").GetString()); + await server.CompleteAsync(); + Assert.Equal(0, server.ExitCode); + Assert.DoesNotContain("An exception occurred", server.StdErr); + } + + [Theory] + [InlineData("theme", "show")] + [InlineData("theme", "edit")] + [InlineData("jq", null)] + [InlineData("exit", null)] + [InlineData("edit", null)] + [InlineData("watch", null)] + [InlineData("welcome", null)] + [InlineData("sproc", null)] + [InlineData("udf", null)] + [InlineData("trigger", null)] + public async Task Stdio_RemovedCommands_AreUnknownToolsAndHelp(string tool, string? action) + { + await using var server = new ServerProcess(); + await server.InitializeAsync("2025-11-25"); + var arguments = action is null ? new Dictionary() : new Dictionary { ["action"] = action }; + var response = await server.CallToolAsync(2, tool, arguments); + Assert.True(response.GetProperty("result").GetProperty("isError").GetBoolean()); + Assert.Contains("Could not find command", response.GetProperty("result").GetProperty("structuredContent").GetProperty("error").GetString()); + var help = await server.CallToolAsync(3, "help", new { command = tool }); + Assert.True(help.GetProperty("result").GetProperty("isError").GetBoolean()); + await server.CompleteAsync(); + Assert.Equal(0, server.ExitCode); + } + + [Theory] + [InlineData("--mcp")] + [InlineData("--lsp")] + [InlineData("--stdio")] + [InlineData("--clear-history")] + [InlineData("-c")] + [InlineData("-k")] + public async Task Stdio_ConflictingModes_FailOnStderrWithEmptyStdout(string option) + { + await using var server = new ServerProcess(option is "-c" or "-k" ? [option, "echo SHOULD_NOT_RUN"] : [option]); + await server.CompleteAsync(); + Assert.Equal(2, server.ExitCode); + Assert.Equal(0, server.MessageCount); + Assert.Contains("cannot be combined", server.StdErr); + Assert.DoesNotContain("SHOULD_NOT_RUN", server.StdErr); + } + + [Theory] + [InlineData("--help", null, 0)] + [InlineData("--version", null, 0)] + [InlineData("--unknown-option", null, 2)] + [InlineData("--container", "orphan", 2)] + [InlineData("--connect", "not-an-endpoint", 2)] + [InlineData("--mcp-stdio=false", null, 2)] + [InlineData("--lsp=true", null, 2)] + [InlineData("--stdio=true", null, 2)] + public async Task Stdio_StartupOutput_IsStderrOnly(string option, string? value, int expectedExitCode) + { + await using var server = new ServerProcess(value is null ? [option] : [option, value]); + await server.CompleteAsync(); + Assert.Equal(expectedExitCode, server.ExitCode); + Assert.Equal(0, server.MessageCount); + Assert.NotEmpty(server.StdErr); + } + + [Fact] + public async Task Stdio_EofWithoutInitialization_ExitsCleanly() + { + await using var server = new ServerProcess(); + await server.CompleteAsync(); + Assert.Equal(0, server.ExitCode); + Assert.Equal(0, server.MessageCount); + } + + private sealed class ServerProcess : IAsyncDisposable + { + private readonly string configDirectory = Path.Join(Path.GetTempPath(), $"cosmosshell-stdio-{Guid.NewGuid():N}"); + private readonly CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + private readonly Channel messages = Channel.CreateUnbounded(); + private readonly Process process; + private readonly Task outputTask; + private readonly Task errorTask; + + private JsonObject? requestMeta; + + public ServerProcess(string[]? extraArguments = null, bool seedHistory = false) + { + if (seedHistory) + { + Directory.CreateDirectory(this.configDirectory); + File.WriteAllText(Path.Join(this.configDirectory, "cmd_history"), "echo EXISTING_HISTORY\n"); + } + + this.timeout.CancelAfter(TimeSpan.FromSeconds(30)); + var shellDll = Path.Join(AppContext.BaseDirectory, "CosmosDBShell.dll"); + Assert.True(File.Exists(shellDll), $"Missing shell executable: {shellDll}"); + var startInfo = new ProcessStartInfo + { + FileName = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet", + WorkingDirectory = AppContext.BaseDirectory, + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardInputEncoding = new UTF8Encoding(false), + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add(shellDll); + startInfo.ArgumentList.Add("--mcp-stdio"); + foreach (var argument in extraArguments ?? []) + { + startInfo.ArgumentList.Add(argument); + } + + startInfo.Environment["COSMOSDB_SHELL_CONFIG_DIR"] = this.configDirectory; + startInfo.Environment.Remove("COSMOSDB_SHELL_ACCOUNT_KEY"); + startInfo.Environment.Remove("COSMOSDB_SHELL_TOKEN"); + startInfo.Environment.Remove("COSMOSDB_SHELL_FORMAT"); + this.process = Process.Start(startInfo) ?? throw new InvalidOperationException("Could not start stdio server."); + this.outputTask = this.CaptureOutputAsync(); + this.errorTask = this.process.StandardError.ReadToEndAsync(); + } + + public List Notifications { get; } = []; + + public string ConfigDirectory => this.configDirectory; + + public int MessageCount { get; private set; } + + public int ExitCode => this.process.ExitCode; + + public string StdErr { get; private set; } = string.Empty; + + public async Task InitializeAsync(string version, object? capabilities = null) + { + if (version == "2026-07-28") + { + this.requestMeta = new JsonObject + { + ["io.modelcontextprotocol/protocolVersion"] = version, + ["io.modelcontextprotocol/clientCapabilities"] = JsonSerializer.SerializeToNode(capabilities ?? new { }), + ["io.modelcontextprotocol/clientInfo"] = new JsonObject { ["name"] = "stdio-process-test", ["version"] = "1.0" }, + }; + var discover = await this.RequestAsync(1, "server/discover", new { }); + Assert.Contains(discover.GetProperty("result").GetProperty("supportedVersions").EnumerateArray(), + supported => supported.GetString() == version); + return; + } + + var response = await this.RequestAsync(1, "initialize", new + { + protocolVersion = version, + capabilities = capabilities ?? new { }, + clientInfo = new { name = "stdio-process-test", version = "1.0" }, + }); + Assert.Equal(version, response.GetProperty("result").GetProperty("protocolVersion").GetString()); + await this.WriteAsync(new { jsonrpc = "2.0", method = "notifications/initialized" }); + } + + public Task CallToolAsync(int id, string name, object arguments) + => this.RequestAsync(id, "tools/call", new { name, arguments }); + + public async Task RequestAsync(int id, string method, object parameters) + { + await this.WriteRequestAsync(id, method, parameters); + return await this.ReadResponseAsync(id); + } + + public async Task WriteRequestAsync(int id, string method, object parameters) + { + var requestParameters = JsonSerializer.SerializeToNode(parameters)!.AsObject(); + if (this.requestMeta is not null) + { + requestParameters["_meta"] = this.requestMeta.DeepClone(); + } + + await this.WriteAsync(new { jsonrpc = "2.0", id, method, @params = requestParameters }); + } + + public async Task WriteAsync(object message) + { + await this.process.StandardInput.WriteLineAsync(JsonSerializer.Serialize(message).AsMemory(), this.timeout.Token); + await this.process.StandardInput.FlushAsync(this.timeout.Token); + } + + public async Task ReadMessageAsync() + { + var message = await this.messages.Reader.ReadAsync(this.timeout.Token); + if (message.TryGetProperty("method", out _) && !message.TryGetProperty("id", out _)) + { + this.Notifications.Add(message); + } + + return message; + } + + public async Task ReadResponseAsync(int id, int[]? cancelledIds = null) + { + while (true) + { + var message = await this.ReadMessageAsync(); + if (message.TryGetProperty("id", out var responseId)) + { + Assert.False(message.TryGetProperty("method", out _), $"Unexpected server request: {message}"); + if (cancelledIds?.Contains(responseId.GetInt32()) == true) + { + Assert.True(message.TryGetProperty("error", out _) + || message.GetProperty("result").GetProperty("isError").GetBoolean(), + $"Cancelled request returned success: {message}"); + continue; + } + + Assert.Equal(id, responseId.GetInt32()); + return message; + } + } + } + +#pragma warning disable VSTHRD003 // Both stream-draining tasks are started by this helper and complete when the child exits. + public async Task CompleteAsync() + { + this.process.StandardInput.Close(); + await this.WaitForExitAsync(); + } + + public async Task WaitForExitAsync() + { + await this.process.WaitForExitAsync(this.timeout.Token); + await this.outputTask; + this.StdErr = await this.errorTask; + } + + public async ValueTask DisposeAsync() + { + try + { + if (!this.process.HasExited) + { + this.process.Kill(entireProcessTree: true); + await this.process.WaitForExitAsync(TestContext.Current.CancellationToken); + } + + await this.outputTask; + await this.errorTask; + } + finally + { + this.process.Dispose(); + this.timeout.Dispose(); + if (Directory.Exists(this.configDirectory)) + { + Directory.Delete(this.configDirectory, recursive: true); + } + } +#pragma warning restore VSTHRD003 + } + + private async Task CaptureOutputAsync() + { + Exception? failure = null; + try + { + while (await this.process.StandardOutput.ReadLineAsync() is { } line) + { + using var document = JsonDocument.Parse(line); + var message = document.RootElement.Clone(); + Assert.Equal("2.0", message.GetProperty("jsonrpc").GetString()); + Assert.True(message.TryGetProperty("method", out _) || message.TryGetProperty("result", out _) || message.TryGetProperty("error", out _), + $"Unexpected non-protocol output: {line}"); + this.MessageCount++; + await this.messages.Writer.WriteAsync(message, this.timeout.Token); + } + } + catch (Exception ex) + { + failure = ex; + throw; + } + finally + { + this.messages.Writer.TryComplete(failure); + } + } + } +} diff --git a/CosmosDBShell.Tests/Integration/ShellProcessTests.cs b/CosmosDBShell.Tests/Integration/ShellProcessTests.cs index 4f3e65a7..a1e90c52 100644 --- a/CosmosDBShell.Tests/Integration/ShellProcessTests.cs +++ b/CosmosDBShell.Tests/Integration/ShellProcessTests.cs @@ -24,6 +24,154 @@ public class ShellProcessTests { private static readonly Regex AnsiEscape = new("\x1b\\[[0-9;?]*[ -/]*[@-~]", RegexOptions.Compiled); + [Theory] + [InlineData("theme use MACHINE_NOPE", "MACHINE_NOPE")] + [InlineData("help MACHINE_MISSING", "MACHINE_MISSING")] + [InlineData("edit machine.txt", "interactive terminal")] + public async Task MachineMode_CommandFailure_ReportsSingleStructuredError(string command, string expected) + { + var result = await RunShellAsync( + stdinScript: null, + extraArgs: ["-c", command], + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(1, result.ExitCode); + Assert.Empty(result.StdOut); + var line = Assert.Single(result.StdErr.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)); + using var error = JsonDocument.Parse(line); + Assert.Equal("error", error.RootElement.GetProperty("status").GetString()); + Assert.Contains(expected, error.RootElement.GetProperty("error").GetString(), StringComparison.Ordinal); + } + + [Fact] + public async Task Quiet_PreservesResultsAndStartupWarningsWithoutAnsi() + { + var result = await RunShellAsync( + stdinScript: null, + extraArgs: ["--quiet", "--theme", "QUIET_UNKNOWN_THEME", "-c", "echo QUIET_RESULT"], + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(0, result.ExitCode); + Assert.Equal("QUIET_RESULT", result.StdOut.Trim()); + Assert.Contains("QUIET_UNKNOWN_THEME", result.StdErr); + Assert.DoesNotContain("Cosmos Shell version:", result.StdOut); + Assert.DoesNotContain("\u001b", result.StdOut + result.StdErr, StringComparison.Ordinal); + } + + [Theory] + [InlineData("save", "saved")] + [InlineData("load", "loaded")] + [InlineData("validate", "valid")] + public async Task Quiet_SuppressesCommandInformationButPreservesStructuredResult(string action, string resultProperty) + { + var file = Path.Join(Path.GetTempPath(), $"cosmosshell-output-{Guid.NewGuid():N}.toml"); + try + { + if (action != "save") + { + await File.WriteAllTextAsync(file, "name = \"OUTPUT_TEST\"\n[colors]\nliteral = \"purple\"\n", TestContext.Current.CancellationToken); + } + + var command = action == "save" + ? $"theme save OUTPUT_TEST '{file}' --force" + : $"theme {action} '{file}'"; + var result = await RunShellAsync( + stdinScript: null, + extraArgs: ["--quiet", "-c", command], + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(0, result.ExitCode); + using var json = JsonDocument.Parse(result.StdOut); + Assert.True(json.RootElement.GetProperty(resultProperty).GetBoolean()); + Assert.Empty(result.StdErr); + } + finally + { + File.Delete(file); + } + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task ThemeValidation_Success_PreservesWarningsOnStderr(bool quiet, bool directory) + { + var path = Path.Join(Path.GetTempPath(), $"cosmosshell-theme-warnings-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + try + { + var file = Path.Join(path, "warning.toml"); + await File.WriteAllTextAsync( + file, + "name = \"WARNING_TEST\"\n[colors]\nunknown_warning_slot = \"red\"\n", + TestContext.Current.CancellationToken); + var target = directory ? path : file; + var command = $"theme validate '{target}'"; + var result = await RunShellAsync( + stdinScript: null, + extraArgs: quiet ? ["--quiet", "-c", command] : ["-c", command], + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(0, result.ExitCode); + using var json = JsonDocument.Parse(result.StdOut); + var validation = directory ? json.RootElement.GetProperty("values")[0] : json.RootElement; + Assert.True(validation.GetProperty("valid").GetBoolean()); + Assert.NotEqual(0, validation.GetProperty("warnings").GetArrayLength()); + Assert.Contains("unknown_warning_slot", result.StdErr); + Assert.DoesNotContain("\u001b", result.StdOut + result.StdErr, StringComparison.Ordinal); + } + finally + { + File.Delete(Path.Join(path, "warning.toml")); + Directory.Delete(path); + } + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task ThemeValidation_FailedDirectoryScan_ReportsOnlyStructuredError(bool quiet, bool strict) + { + var path = Path.Join(Path.GetTempPath(), $"cosmosshell-theme-errors-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + try + { + await File.WriteAllTextAsync( + Path.Join(path, "a-warning.toml"), + "name = \"WARNING_TEST\"\n[colors]\nunknown_warning_slot = \"red\"\n", + TestContext.Current.CancellationToken); + if (!strict) + { + await File.WriteAllTextAsync( + Path.Join(path, "z-broken.toml"), + "name = \"BROKEN_TEST\"\n[colors]\nliteral = \"not_a_color\"\n", + TestContext.Current.CancellationToken); + } + + var command = $"theme validate '{path}'" + (strict ? " --strict" : string.Empty); + var result = await RunShellAsync( + stdinScript: null, + extraArgs: quiet ? ["--quiet", "-c", command] : ["-c", command], + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(1, result.ExitCode); + Assert.Empty(result.StdOut); + var line = Assert.Single(result.StdErr.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)); + using var error = JsonDocument.Parse(line); + Assert.Equal("error", error.RootElement.GetProperty("status").GetString()); + Assert.DoesNotContain("unknown_warning_slot", result.StdErr); + } + finally + { + File.Delete(Path.Join(path, "a-warning.toml")); + File.Delete(Path.Join(path, "z-broken.toml")); + Directory.Delete(path); + } + } + [Theory] [InlineData("value", "identity", "1 argument, got 0")] [InlineData("value", "identity 1 2", "1 argument, got 2")] @@ -93,6 +241,64 @@ public async Task StdinPipedScript_EchoCommand_WritesArgumentToStdOut() Assert.Contains("hello from process", result.StdOut); } + [Theory] + [InlineData(null)] + [InlineData("--help")] + [InlineData("--lsp")] + [InlineData("--stdio")] + public async Task StdioOption_InResponseFile_IsRejectedWithoutStdout(string? additionalArgument) + { + var responseFile = Path.Join(Path.GetTempPath(), $"cosmosshell-stdio-{Guid.NewGuid():N}.rsp"); + await File.WriteAllTextAsync(responseFile, "--mcp-stdio", TestContext.Current.CancellationToken); + try + { + var result = await RunShellAsync( + stdinScript: null, + extraArgs: additionalArgument is null ? ["@" + responseFile] : ["@" + responseFile, additionalArgument], + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(2, result.ExitCode); + Assert.Empty(result.StdOut); + Assert.Contains("response file", result.StdErr); + } + finally + { + File.Delete(responseFile); + } + } + + [Fact] + public async Task StdioOption_InResponseFile_WithDirectFalseValue_DoesNotStartProtocol() + { + var responseFile = Path.Join(Path.GetTempPath(), $"cosmosshell-stdio-{Guid.NewGuid():N}.rsp"); + await File.WriteAllTextAsync(responseFile, "--mcp-stdio", TestContext.Current.CancellationToken); + try + { + var result = await RunShellAsync( + stdinScript: null, + extraArgs: ["@" + responseFile, "--mcp-stdio=false"], + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(2, result.ExitCode); + Assert.Empty(result.StdOut); + Assert.Contains("--mcp-stdio cannot be combined", result.StdErr); + } + finally + { + File.Delete(responseFile); + } + } + + [Fact] + public async Task StdioOption_InCommandTail_RemainsCommandText() + { + var result = await RunShellAsync( + stdinScript: null, + extraArgs: ["-c", "echo \"--mcp-stdio\""], + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(0, result.ExitCode); + Assert.Contains("--mcp-stdio", result.StdOut); + Assert.Empty(result.StdErr); + } + [Fact] public async Task InvariantGlobalization_ExecuteCommand_LoadsEnglishCatalog() { @@ -128,7 +334,7 @@ public async Task ProcessStdOut_CanBePipedToFile() { // Emulates 'cosmosdbshell < script.txt > output.txt': the caller captures the // process's stdout and writes it to a file, then verifies the command ran. - var outFile = Path.Combine(Path.GetTempPath(), $"cosmosshell-proc-{Guid.NewGuid():N}.txt"); + var outFile = Path.Join(Path.GetTempPath(), $"cosmosshell-proc-{Guid.NewGuid():N}.txt"); try { var result = await RunShellAsync("echo \"PIPE_SMOKE_TEST\"", TestContext.Current.CancellationToken); @@ -490,7 +696,7 @@ private static async Task RunShellAsync( || (argsList?.Contains("-c") == true) || (argsList?.Contains("-k") == true); - var shellDll = Path.Combine(AppContext.BaseDirectory, "CosmosDBShell.dll"); + var shellDll = Path.Join(AppContext.BaseDirectory, "CosmosDBShell.dll"); if (!File.Exists(shellDll)) { throw SkipException.ForSkip($"CosmosDBShell.dll not found next to test assembly at '{shellDll}'."); diff --git a/CosmosDBShell.Tests/McpConfirmationTests.cs b/CosmosDBShell.Tests/McpConfirmationTests.cs index 238e847b..bd19ae7d 100644 --- a/CosmosDBShell.Tests/McpConfirmationTests.cs +++ b/CosmosDBShell.Tests/McpConfirmationTests.cs @@ -4,6 +4,7 @@ namespace CosmosShell.Tests; +using System.IO.Pipelines; using System.Text.Json; using Azure.Data.Cosmos.Shell.Mcp; using Microsoft.AspNetCore.Hosting.Server; @@ -16,6 +17,50 @@ namespace CosmosShell.Tests; [Collection(CosmosShell.Tests.Shell.ThemeStateTestCollection.Name)] public class McpConfirmationTests { + [Theory] + [InlineData(null, "2026-07-28")] + [InlineData("2025-11-25", "2025-11-25")] + public async Task DestructiveCommand_OverStdio_AsksClientAndHonorsDecline(string? requestedVersion, string negotiatedVersion) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(10)); + var input = new Pipe(); + var output = new Pipe(); + using var host = McpServer.CreateStdioHost(input.Reader.AsStream(), output.Writer.AsStream()); + await host.StartAsync(timeout.Token); + try + { + var prompts = new List(); + await using var client = await McpClient.CreateAsync( + new StreamClientTransport(input.Writer.AsStream(), output.Reader.AsStream()), + new McpClientOptions + { + ProtocolVersion = requestedVersion, + Handlers = new McpClientHandlers + { + ElicitationHandler = (request, _) => + { + prompts.Add(request?.Message); + return ValueTask.FromResult(new ElicitResult { Action = "decline" }); + }, + }, + }, + cancellationToken: timeout.Token); + Assert.Equal(negotiatedVersion, client.NegotiatedProtocolVersion); + var result = await client.CallToolAsync( + "rmdb", + new Dictionary { ["name"] = "StdioConfirmationTestDb" }, + cancellationToken: timeout.Token); + Assert.Contains("StdioConfirmationTestDb", Assert.Single(prompts)); + Assert.True(result.IsError); + Assert.Contains("was not approved", Assert.IsType(Assert.Single(result.Content)).Text); + } + finally + { + await host.StopAsync(TestContext.Current.CancellationToken); + } + } + [Theory] [InlineData(null, "2026-07-28")] // Stateless request; confirmation uses native multi-round-trip requests. [InlineData("2025-11-25", "2025-11-25")] // Initialize handshake; confirmation is sent over the session. diff --git a/CosmosDBShell.Tests/McpLocationSubscriptionTests.cs b/CosmosDBShell.Tests/McpLocationSubscriptionTests.cs index 2df82edb..6f68af8b 100644 --- a/CosmosDBShell.Tests/McpLocationSubscriptionTests.cs +++ b/CosmosDBShell.Tests/McpLocationSubscriptionTests.cs @@ -4,6 +4,7 @@ namespace CosmosShell.Tests; +using System.IO.Pipelines; using System.Text.Json; using Azure.Data.Cosmos.Shell.Core; using Azure.Data.Cosmos.Shell.Mcp; @@ -20,6 +21,64 @@ namespace CosmosShell.Tests; [Collection(CosmosShell.Tests.Shell.ThemeStateTestCollection.Name)] public class McpLocationSubscriptionTests { + [Fact] + public async Task StdioSubscription_ReceivesLocationChangeWithoutHttpSession() + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(10)); + var input = new Pipe(); + var output = new Pipe(); + using var host = McpServer.CreateStdioHost(input.Reader.AsStream(), output.Writer.AsStream()); + Assert.Null(host.Services.GetService()); + await host.StartAsync(timeout.Token); + var subscriptions = host.Services.GetRequiredService(); + try + { + await using var client = await McpClient.CreateAsync( + new StreamClientTransport(input.Writer.AsStream(), output.Reader.AsStream()), + new McpClientOptions { ProtocolVersion = "2025-11-25" }, + cancellationToken: timeout.Token); + Assert.NotNull(client.ServerCapabilities.Resources); + Assert.True(client.ServerCapabilities.Resources.Subscribe); + Assert.False(client.ServerCapabilities.Resources.ListChanged); + var updated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var subscription = await client.SubscribeToResourceAsync( + ResourceOperations.CurrentLocationUri, + (notification, _) => + { + updated.TrySetResult(notification.Uri); + return ValueTask.CompletedTask; + }, + cancellationToken: timeout.Token); + Assert.Equal(1, subscriptions.SubscriberCount); + var originalState = ShellInterpreter.Instance.State; + using var cosmosClient = new CosmosClient( + "https://localhost:8081", + Convert.ToBase64String(new byte[64]), + new CosmosClientOptions { ConnectionMode = ConnectionMode.Gateway }); + try + { + ShellInterpreter.Instance.State = new DatabaseState("StdioNotificationTest", cosmosClient); + Assert.Equal(ResourceOperations.CurrentLocationUri, await updated.Task.WaitAsync(timeout.Token)); + var resource = await client.ReadResourceAsync(ResourceOperations.CurrentLocationUri, cancellationToken: timeout.Token); + using var json = JsonDocument.Parse(Assert.IsType(Assert.Single(resource.Contents)).Text); + Assert.Equal("/StdioNotificationTest", json.RootElement.GetProperty("currentLocation").GetString()); + await client.UnsubscribeFromResourceAsync(ResourceOperations.CurrentLocationUri, cancellationToken: timeout.Token); + Assert.Equal(0, subscriptions.SubscriberCount); + } + finally + { + ShellInterpreter.Instance.State = originalState; + } + } + finally + { + await host.StopAsync(TestContext.Current.CancellationToken); + } + + Assert.Equal(0, subscriptions.SubscriberCount); + } + [Fact] public async Task SubscribedClient_ReceivesInteractiveLocationChange() { diff --git a/CosmosDBShell.Tests/Parser/StatementPositionalErrorTests.cs b/CosmosDBShell.Tests/Parser/StatementPositionalErrorTests.cs index b5dcf161..24d93f89 100644 --- a/CosmosDBShell.Tests/Parser/StatementPositionalErrorTests.cs +++ b/CosmosDBShell.Tests/Parser/StatementPositionalErrorTests.cs @@ -7,6 +7,7 @@ namespace CosmosShell.Tests.Parser; using System.Threading; using System.Threading.Tasks; +using Azure.Data.Cosmos.Shell.Commands; using Azure.Data.Cosmos.Shell.Core; using Azure.Data.Cosmos.Shell.Parser; @@ -35,18 +36,20 @@ public async Task ThrownExpressionError_UsesCommandLocation(string source) } [Theory] - [InlineData("$value = (help totallyunknowncmd999)")] - [InlineData("if (help totallyunknowncmd999) {}")] - [InlineData("for $item in (help totallyunknowncmd999) {}")] + [InlineData("$value = (returned-error-test)")] + [InlineData("if (returned-error-test) {}")] + [InlineData("for $item in (returned-error-test) {}")] public async Task ReturnedExpressionError_UsesCommandLocation(string source) { + Assert.True(CommandFactory.TryCreateFactory(typeof(ReturnedErrorTestCommand), out var factory)); + Shell.App.Commands["returned-error-test"] = factory; Shell.CurrentScriptFileName = "expression.csh"; Shell.CurrentScriptContent = source; var statement = new StatementParser(source).ParseStatement()!; var exception = await Assert.ThrowsAsync(() => statement.RunAsync(Shell, new(), TestContext.Current.CancellationToken)); var frame = PositionalException.GetSourceTrace(exception)[0]; Assert.Equal("expression.csh", frame.FileName); - Assert.Equal(source.IndexOf("help", StringComparison.Ordinal) + 1, frame.Column); + Assert.Equal(source.IndexOf("returned-error-test", StringComparison.Ordinal) + 1, frame.Column); var failure = Assert.IsType(frame.InnerException); Assert.IsType(failure.State); } @@ -66,6 +69,13 @@ public async Task InteractiveFunction_RetainsScriptModeWithoutBorrowingCallerOff Assert.Equal("\nprobe", Shell.CurrentScriptContent); } + [CosmosCommand("returned-error-test")] + internal sealed class ReturnedErrorTestCommand : CosmosCommand + { + public override Task ExecuteAsync(ShellInterpreter shell, CommandState commandState, string commandText, CancellationToken token) + => Task.FromResult(new ErrorCommandState(new CommandException("returned-error-test", "failed"))); + } + private sealed class ScriptContextProbe : Statement { public override int Start => 100; diff --git a/CosmosDBShell.Tests/Shell/ShellConfigDirectoryTests.cs b/CosmosDBShell.Tests/Shell/ShellConfigDirectoryTests.cs index 281aacd8..96c9d6d9 100644 --- a/CosmosDBShell.Tests/Shell/ShellConfigDirectoryTests.cs +++ b/CosmosDBShell.Tests/Shell/ShellConfigDirectoryTests.cs @@ -19,6 +19,26 @@ public sealed class ShellConfigDirectoryTestCollection [Collection(ShellConfigDirectoryTestCollection.Name)] public class ShellConfigDirectoryTests { + [Fact] + public void DisabledHistory_DoesNotLoadExistingCommands() + { + var isolatedDir = Path.Join(Path.GetTempPath(), $"cosmosshell-no-history-{Guid.NewGuid():N}"); + Directory.CreateDirectory(isolatedDir); + var historyFile = Path.Join(isolatedDir, "cmd_history"); + File.WriteAllText(historyFile, "echo HISTORY_SENTINEL\n"); + try + { + using var shell = new ShellInterpreter(isolatedDir, historyEnabled: false); + Assert.Empty(shell.History); + Assert.Equal(0, shell.PendingHistoryCount); + Assert.Equal("echo HISTORY_SENTINEL\n", File.ReadAllText(historyFile)); + } + finally + { + Directory.Delete(isolatedDir, recursive: true); + } + } + [Fact] public void ConfigDirEnvVar_RedirectsHistoryFileAwayFromRealUserDirectory() { diff --git a/CosmosDBShell.Tests/StdioInputStreamTests.cs b/CosmosDBShell.Tests/StdioInputStreamTests.cs new file mode 100644 index 00000000..bb84d11b --- /dev/null +++ b/CosmosDBShell.Tests/StdioInputStreamTests.cs @@ -0,0 +1,36 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests; + +using Azure.Data.Cosmos.Shell.Mcp; +using Microsoft.Extensions.Hosting; +using NSubstitute; + +public class StdioInputStreamTests +{ + [Fact] + public async Task Reads_StopHostOnlyAtActualEof() + { + var lifetime = Substitute.For(); + using var input = new StdioInputStream(new MemoryStream([42])) { HostLifetime = lifetime }; + var buffer = new byte[1]; + Assert.Equal(0, await input.ReadAsync(Memory.Empty, TestContext.Current.CancellationToken)); + lifetime.DidNotReceive().StopApplication(); + Assert.Equal(1, await input.ReadAsync(buffer.AsMemory(), TestContext.Current.CancellationToken)); + Assert.Equal(42, buffer[0]); + lifetime.DidNotReceive().StopApplication(); + Assert.Equal(0, await input.ReadAsync(buffer.AsMemory(), TestContext.Current.CancellationToken)); + lifetime.Received(1).StopApplication(); + } + + [Fact] + public void SynchronousEof_StopsHost() + { + var lifetime = Substitute.For(); + using var input = new StdioInputStream(new MemoryStream()) { HostLifetime = lifetime }; + Assert.Equal(0, input.Read(new byte[1], 0, 1)); + lifetime.Received(1).StopApplication(); + } +} diff --git a/CosmosDBShell.Tests/StdioStartupInputTests.cs b/CosmosDBShell.Tests/StdioStartupInputTests.cs new file mode 100644 index 00000000..696b996d --- /dev/null +++ b/CosmosDBShell.Tests/StdioStartupInputTests.cs @@ -0,0 +1,298 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests; + +using System.IO.Pipelines; +using System.Diagnostics; +using System.Text; +using Azure.Data.Cosmos.Shell.Mcp; + +public class StdioStartupInputTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Dispose_DeletesStartupSpoolWithoutWaitingForUninterruptibleInput(bool timedOut) + { + using var source = new UninterruptibleInput(); + using var input = new StdioStartupInput( + source, timedOut ? TimeSpan.FromMilliseconds(50) : TimeSpan.FromSeconds(60)); + input.Start(); + try + { + await source.Blocked.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + if (timedOut) + { + await Assert.ThrowsAnyAsync(() => + Task.Delay(Timeout.InfiniteTimeSpan, input.StartupToken) + .WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)); + Assert.True(input.TimedOut); + } + + input.Dispose(); + + Assert.False(source.Release.Task.IsCompleted); + Assert.False(File.Exists(input.StartupBufferPath)); + } + finally + { + source.Release.TrySetResult(); + } + } + + [Fact] + public async Task CompletedStartup_ReusesSpoolHandleAndDeletesItAfterReading() + { + using var source = new UninterruptibleInput(); + using var input = new StdioStartupInput(source); + input.Start(); + try + { + await source.Blocked.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + await input.CompleteStartupAsync(); + source.Release.TrySetResult(); + using var received = new MemoryStream(); + await input.Input.CopyToAsync(received, TestContext.Current.CancellationToken); + + Assert.Equal(UninterruptibleInput.Prefix, received.ToArray()); + Assert.False(File.Exists(input.StartupBufferPath)); + } + finally + { + source.Release.TrySetResult(); + } + } + + [Fact] + public async Task Eof_CancelsStartupAndPreservesBufferedProtocolBytes() + { + var source = new Pipe(); + using var input = new StdioStartupInput(source.Reader.AsStream()); + input.Start(); + var bytes = Encoding.UTF8.GetBytes("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}\n"); + await source.Writer.WriteAsync(bytes, TestContext.Current.CancellationToken); + await source.Writer.CompleteAsync(); + + await Assert.ThrowsAnyAsync(() => + Task.Delay(Timeout.InfiniteTimeSpan, input.StartupToken) + .WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)); + Assert.False(input.TimedOut); + Assert.Null(input.InputError); + Assert.Empty(ReportCancellation(input).Messages); + using var received = new MemoryStream(); + await input.Input.CopyToAsync(received, TestContext.Current.CancellationToken); + Assert.Equal(bytes, received.ToArray()); + } + + [Fact] + public async Task PendingInput_StartupDeadlineCancelsWithoutEof() + { + Assert.Equal(TimeSpan.FromSeconds(60), StdioStartupInput.StartupTimeout); + var source = new Pipe(); + using var input = new StdioStartupInput(source.Reader.AsStream(), TimeSpan.FromMilliseconds(100)); + input.Start(); + + await Assert.ThrowsAnyAsync(() => + Task.Delay(Timeout.InfiniteTimeSpan, input.StartupToken) + .WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)); + Assert.True(input.TimedOut); + Assert.Null(input.InputError); + await source.Writer.CompleteAsync(); + } + + [Fact] + public async Task CompletedStartup_DisablesDeadlineAndContinuesReading() + { + var source = new Pipe(); + using var input = new StdioStartupInput(source.Reader.AsStream(), TimeSpan.FromMilliseconds(100)); + input.Start(); + await input.CompleteStartupAsync(); + await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken); + Assert.False(input.StartupToken.IsCancellationRequested); + Assert.False(input.TimedOut); + + await source.Writer.WriteAsync(new byte[] { 42 }, TestContext.Current.CancellationToken); + var received = new byte[1]; + Assert.Equal(1, await input.Input.ReadAsync(received.AsMemory(), TestContext.Current.CancellationToken)); + Assert.Equal(42, received[0]); + await source.Writer.CompleteAsync(); + } + + [Fact] + public async Task LargeQueuedInput_DrainsThroughEofAndPreservesBytes() + { + var source = new Pipe(); + using var input = new StdioStartupInput(source.Reader.AsStream()); + input.Start(); + var bytes = new byte[256 * 1024]; + Random.Shared.NextBytes(bytes); + + await source.Writer.WriteAsync(bytes, TestContext.Current.CancellationToken); + await source.Writer.CompleteAsync(); + await Assert.ThrowsAnyAsync(() => + Task.Delay(Timeout.InfiniteTimeSpan, input.StartupToken) + .WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)); + + using var received = new MemoryStream(); + await input.Input.CopyToAsync(received, TestContext.Current.CancellationToken); + Assert.Equal(bytes, received.ToArray()); + Assert.False(input.TimedOut); + } + + [Fact] + public async Task ProductionDeadline_IsSixtySecondsAndTimeoutReportsConnectionError() + { + Assert.Equal(TimeSpan.FromSeconds(60), StdioStartupInput.StartupTimeout); + var source = new Pipe(); + using var input = new StdioStartupInput(source.Reader.AsStream(), TimeSpan.FromMilliseconds(50)); + input.Start(); + await Assert.ThrowsAnyAsync(() => + Task.Delay(Timeout.InfiniteTimeSpan, input.StartupToken) + .WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)); + + await source.Writer.CompleteAsync(); + var received = new byte[1]; + Assert.Equal(0, await input.Input.ReadAsync(received, TestContext.Current.CancellationToken)); + Assert.True(input.TimedOut); + var (exitCode, messages) = ReportCancellation(input); + Assert.Equal(4, exitCode); + Assert.Contains("timed out after 60 seconds", Assert.Single(messages)); + } + + [Fact] + public async Task InputFailure_CancelsStartupAndSurfacesOriginalError() + { + var error = new IOException("STDIO_INPUT_FAILURE"); + using var input = new StdioStartupInput(new FaultingInput(error)); + input.Start(); + await Assert.ThrowsAnyAsync(() => + Task.Delay(Timeout.InfiniteTimeSpan, input.StartupToken) + .WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)); + Assert.Same(error, input.InputError); + Assert.False(input.TimedOut); + var (exitCode, messages) = ReportCancellation(input); + Assert.Equal(1, exitCode); + Assert.Contains("STDIO_INPUT_FAILURE", Assert.Single(messages)); + var received = new byte[1]; + var actual = await Assert.ThrowsAsync(() => + input.Input.ReadAsync(received.AsMemory(), TestContext.Current.CancellationToken).AsTask()); + Assert.Same(error, actual); + } + + [Fact] + public async Task StartupBuffer_ExactlyAtLimit_PreservesAllBytesWithoutCancellation() + { + Assert.Equal(8 * 1024 * 1024, StdioStartupInput.MaximumStartupBufferBytes); + var bytes = new byte[StdioStartupInput.MaximumStartupBufferBytes]; + Random.Shared.NextBytes(bytes); + using var source = new UninterruptibleInput(bytes); + using var input = new StdioStartupInput(source); + input.Start(); + try + { + await source.Blocked.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + Assert.False(input.StartupToken.IsCancellationRequested); + Assert.Null(input.InputError); + await input.CompleteStartupAsync(); + source.Release.TrySetResult(); + using var received = new MemoryStream(); + await input.Input.CopyToAsync(received, TestContext.Current.CancellationToken); + + Assert.Equal(bytes, received.ToArray()); + Assert.False(File.Exists(input.StartupBufferPath)); + } + finally + { + source.Release.TrySetResult(); + } + } + + [Fact] + public async Task StartupBuffer_OneByteOverLimit_CancelsReportsErrorAndNeverExceedsLimit() + { + using var source = new MemoryStream(new byte[StdioStartupInput.MaximumStartupBufferBytes + 1]); + using var input = new StdioStartupInput(source); + input.Start(); + await Assert.ThrowsAnyAsync(() => + Task.Delay(Timeout.InfiniteTimeSpan, input.StartupToken) + .WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)); + + Assert.False(input.TimedOut); + var error = Assert.IsType(input.InputError); + var (exitCode, messages) = ReportCancellation(input); + Assert.Equal(1, exitCode); + Assert.Contains("8 MiB", Assert.Single(messages)); + using var received = new MemoryStream(); + var actual = await Assert.ThrowsAsync(() => + input.Input.CopyToAsync(received, TestContext.Current.CancellationToken)); + Assert.Same(error, actual); + Assert.Equal(StdioStartupInput.MaximumStartupBufferBytes, received.Length); + Assert.False(File.Exists(input.StartupBufferPath)); + } + + [Fact] + public async Task CompletedStartup_InputOverStartupLimit_IsNotRejected() + { + var bytes = new byte[StdioStartupInput.MaximumStartupBufferBytes + 1]; + Random.Shared.NextBytes(bytes); + using var source = new MemoryStream(bytes); + using var input = new StdioStartupInput(source); + await input.CompleteStartupAsync(); + input.Start(); + using var received = new MemoryStream(); + await input.Input.CopyToAsync(received, TestContext.Current.CancellationToken); + + Assert.Equal(bytes, received.ToArray()); + Assert.Null(input.InputError); + Assert.False(input.TimedOut); + } + + private sealed class FaultingInput(Exception error) : MemoryStream + { + public override ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + => ValueTask.FromException(error); + } + + private sealed class UninterruptibleInput(byte[]? prefix = null) : MemoryStream(prefix ?? Prefix) + { + internal static readonly byte[] Prefix = Encoding.UTF8.GetBytes("{\"jsonrpc\":\"2.0\",\"method\":\"ping\"}\n"); + + internal TaskCompletionSource Blocked { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + internal TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public override async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + { + if (this.Position < this.Length) + { + return await base.ReadAsync(buffer, cancellationToken); + } + + this.Blocked.TrySetResult(); + await this.Release.Task; + return 0; + } + } + + private static (int ExitCode, List Messages) ReportCancellation(StdioStartupInput input) + { + var originalExitCode = Environment.ExitCode; + try + { + Environment.ExitCode = 0; + var messages = new List(); + Program.ReportStdioStartupCancellation(input, messages.Add); + return (Environment.ExitCode, messages); + } + finally + { + Environment.ExitCode = originalExitCode; + } + } +} diff --git a/CosmosDBShell.Tests/ToolOperationsCallToolTests.cs b/CosmosDBShell.Tests/ToolOperationsCallToolTests.cs index 1e06c319..12fe38e4 100644 --- a/CosmosDBShell.Tests/ToolOperationsCallToolTests.cs +++ b/CosmosDBShell.Tests/ToolOperationsCallToolTests.cs @@ -18,6 +18,7 @@ namespace CosmosShell.Tests; using Azure.Data.Cosmos.Shell.States; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -119,6 +120,203 @@ public async Task CallTool_RestrictedCommand_ReturnsError() Assert.True(isError); Assert.Contains("restricted for MCP", root.GetProperty("error").GetString()); } + + } + + [Fact] + public void StdioCommandRemoval_RemovesUnsupportedCommandsAndAliasesButKeepsConfirmableCommands() + { + var runner = new CommandRunner(); + var expectedRemaining = runner.Commands.Where(entry => !ToolOperations.IsUnavailableInStdio(entry.Value)).Select(entry => entry.Key).ToList(); + Assert.Contains(runner.Commands.Values, command => command.CommandName == "jq"); + Assert.Contains(runner.Commands.Values, command => command.CommandName == "theme"); + + runner.RemoveCommands(ToolOperations.IsUnavailableInStdio); + + Assert.DoesNotContain(runner.Commands.Values, command => command.CommandName is "jq" or "theme"); + foreach (var name in new[] { "exit", "edit", "watch", "welcome", "sproc", "udf", "trigger" }) + { + Assert.False(runner.Commands.ContainsKey(name), $"Unsupported command '{name}' remains registered."); + } + + foreach (var name in new[] { "delete", "rm", "rmdb", "rmcon" }) + { + Assert.True(runner.Commands.ContainsKey(name), $"Confirmable command '{name}' was removed."); + } + + Assert.Equal(expectedRemaining.Order(), runner.Commands.Keys.Order()); + Assert.True(ShellInterpreter.Instance.App.Commands.ContainsKey("theme")); + } + + [Fact] + public void Stdio_PrintCommand_DoesNotRecordInMemoryHistory() + { + var shell = ShellInterpreter.Instance; + var originalOptions = shell.Options; + var originalHistory = shell.History.ToArray(); + var originalPending = shell.PendingHistoryCount; + try + { + shell.Options = new Program.CosmosShellOptions { McpStdio = true }; + shell.PrintCommand("echo STDIO_NO_HISTORY"); + Assert.Equal(originalHistory, shell.History); + Assert.Equal(originalPending, shell.PendingHistoryCount); + } + finally + { + shell.Options = originalOptions; + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Stdio_ApprovedConfirmation_IsScopedToCommandAndNeverReadsConsole(bool failAfterConfirmation) + { + var shell = ShellInterpreter.Instance; + var originalOptions = shell.Options; + var originalApproval = shell.McpConfirmationApproved; + shell.Options = new Program.CosmosShellOptions { McpStdio = true }; + shell.McpConfirmationApproved = false; + try + { + Assert.Throws(() => ShellInterpreter.Confirm("command-rmdb-confirm_db_deletion")); + var result = await this.CreateToolOperations().ExecuteToolAsync( + shell.App.Commands["rmdb"], + new ConfirmationCheckingCommand(failAfterConfirmation), + "rmdb StdioScopeTest", + (_, _, _) => ValueTask.FromResult(new ElicitResult { Action = "accept" }), + TestContext.Current.CancellationToken); + Assert.Equal(failAfterConfirmation, result.IsError == true); + Assert.False(shell.McpConfirmationApproved); + Assert.Throws(() => ShellInterpreter.Confirm("command-rmdb-confirm_db_deletion")); + } + finally + { + shell.McpConfirmationApproved = originalApproval; + shell.Options = originalOptions; + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CancelledTool_PropagatesCancellationWithoutLoggingFailure(bool queued) + { + var shell = ShellInterpreter.Instance; + var logger = new RecordingToolLogger(); + var tools = new ToolOperations(logger, this.locationSubscriptions); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(5)); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token); + var gateEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var gate = queued + ? shell.RunSerializedAsync( + async () => + { + gateEntered.SetResult(); + await releaseGate.Task.WaitAsync(timeout.Token); + return true; + }, timeout.Token) + : Task.FromResult(true); + var command = new CancellationCheckingCommand(); + try + { + if (queued) + { + await gateEntered.Task.WaitAsync(timeout.Token); + } + + var call = tools.ExecuteToolAsync( + shell.App.Commands["echo"], command, "echo CANCELLED_TOOL", null, cancellation.Token); + if (!queued) + { + await command.Started.Task.WaitAsync(timeout.Token); + } + + await cancellation.CancelAsync(); + await Assert.ThrowsAnyAsync(() => call.WaitAsync(timeout.Token)); + Assert.Equal(!queued, command.Started.Task.IsCompleted); + Assert.DoesNotContain(LogLevel.Error, logger.Levels); + } + finally + { + releaseGate.TrySetResult(); + await gate; + } + + var next = await tools.CallToolHandler( + CallContext("echo", new() { ["messages"] = Json("[\"AFTER_CANCELLATION\"]") }), timeout.Token); + Assert.False(next.IsError == true); + Assert.Contains("AFTER_CANCELLATION", Assert.IsType(Assert.Single(next.Content)).Text); + } + + [Fact] + public async Task CancelledConfirmation_NeverExecutesCommandAndReleasesShell() + { + var shell = ShellInterpreter.Instance; + var tools = this.CreateToolOperations(); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(5)); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token); + var prompted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var command = new CancellationCheckingCommand(); + var call = tools.ExecuteToolAsync( + shell.App.Commands["rmdb"], command, "rmdb CANCELLED_CONFIRMATION", + async (_, _, token) => + { + prompted.SetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, token); + return new ElicitResult { Action = "accept" }; + }, cancellation.Token); + await prompted.Task.WaitAsync(timeout.Token); + await cancellation.CancelAsync(); + await Assert.ThrowsAnyAsync(() => call.WaitAsync(timeout.Token)); + Assert.False(command.Started.Task.IsCompleted); + Assert.False(shell.McpConfirmationApproved); + Assert.True(await shell.RunSerializedAsync(() => Task.FromResult(true), timeout.Token)); + } + + private sealed class CancellationCheckingCommand : CosmosCommand + { + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public override async Task ExecuteAsync( + ShellInterpreter shell, CommandState commandState, string commandText, CancellationToken token) + { + this.Started.SetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, token); + return commandState; + } + } + + private sealed class RecordingToolLogger : ILogger + { + public List Levels { get; } = []; + + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + => this.Levels.Add(logLevel); + } + + private sealed class ConfirmationCheckingCommand(bool failAfterConfirmation) : CosmosCommand + { + public override Task ExecuteAsync(ShellInterpreter shell, CommandState commandState, string commandText, CancellationToken token) + { + Assert.True(ShellInterpreter.Confirm("command-rmdb-confirm_db_deletion")); + if (failAfterConfirmation) + { + throw new InvalidOperationException("Confirmation scope test failure."); + } + + return Task.FromResult(commandState); + } } [Fact] diff --git a/CosmosDBShell.Tests/UtilTest/OutputPolicyTests.cs b/CosmosDBShell.Tests/UtilTest/OutputPolicyTests.cs index f7ed61c3..9e9df082 100644 --- a/CosmosDBShell.Tests/UtilTest/OutputPolicyTests.cs +++ b/CosmosDBShell.Tests/UtilTest/OutputPolicyTests.cs @@ -85,6 +85,17 @@ public void DefaultOutputFormat_QuietWithoutFormat_FallsBackToJson() Assert.Equal(OutputFormat.JSon, shell.DefaultOutputFormat); } + [Fact] + public void StdioMode_IsHeadlessWithoutSuppressingStderrDiagnostics() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.Options = new Program.CosmosShellOptions { McpStdio = true }; + Assert.True(shell.IsMachineMode); + Assert.False(shell.Options.Quiet); + Assert.Equal(OutputFormat.JSon, shell.DefaultOutputFormat); + Assert.False(InfoCommand.ShouldRenderTables(null, shell, new CommandState())); + } + [Fact] public void DefaultOutputFormat_ExecuteAndQuitWithoutFormat_FallsBackToJson() { diff --git a/CosmosDBShell.Tests/UtilTest/ShellOutputTests.cs b/CosmosDBShell.Tests/UtilTest/ShellOutputTests.cs new file mode 100644 index 00000000..87b1437c --- /dev/null +++ b/CosmosDBShell.Tests/UtilTest/ShellOutputTests.cs @@ -0,0 +1,158 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.UtilTest; + +using Azure.Data.Cosmos.Shell.Core; +using Spectre.Console; + +public class ShellOutputTests +{ + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void Presentation_UsesCategoryPolicyForTextMarkupAndRenderables(bool quiet, bool stdio) + { + foreach (var kind in Enum.GetValues()) + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + var output = CreateOutput(quiet, stdio, stdout, stderr); + var marker = kind.ToString(); + + output.Write(kind, marker + "_text"); + output.WriteLine(kind, marker + "_line"); + output.Markup(kind, $"[red]{marker}_markup[/]"); + output.MarkupLine(kind, $"[red]{marker}_markupLine[/]"); + output.Render(kind, new Text(marker + "_renderable")); + output.RenderLine(kind, marker + "_renderLine"); + output.WriteException(kind, new InvalidOperationException(marker + "_exception")); + + // CreateOutput treats quiet or stdio as machine mode, which suppresses command-level + // result presentation; machine-mode results go through WriteResult instead. + var machineMode = quiet || stdio; + var suppressed = (quiet && kind is ShellMessageKind.Information or ShellMessageKind.Progress) + || ((stdio || machineMode) && kind == ShellMessageKind.Result); + if (suppressed) + { + Assert.Empty(stdout.ToString()); + Assert.Empty(stderr.ToString()); + continue; + } + + var useStderr = stdio || (quiet && kind is ShellMessageKind.Warning or ShellMessageKind.Error or ShellMessageKind.RequiredInstruction); + var visible = (useStderr ? stderr : stdout).ToString(); + Assert.Empty((useStderr ? stdout : stderr).ToString()); + foreach (var suffix in new[] { "_text", "_line", "_markup", "_markupLine", "_renderable", "_renderLine", "_exception" }) + { + Assert.Contains(marker + suffix, visible); + } + + if (machineMode) + { + Assert.DoesNotContain("\u001b", visible, StringComparison.Ordinal); + } + } + } + + [Fact] + public void StructuredMode_RoutesWarningsErrorsAndAuthenticationToStderr() + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + var output = CreateOutput(false, false, stdout, stderr, machineMode: true); + output.MarkupLine(ShellMessageKind.Warning, "[yellow]warning[/]"); + output.WriteLine(ShellMessageKind.Error, "error"); + output.WriteLine(ShellMessageKind.RequiredInstruction, "login"); + output.WriteLine(ShellMessageKind.Result, "eager table"); + output.Render(ShellMessageKind.Result, new Text("eager renderable")); + output.WriteResult("{\"result\":true}"); + Assert.Equal("{\"result\":true}" + Environment.NewLine, stdout.ToString()); + Assert.DoesNotContain("eager", stderr.ToString(), StringComparison.Ordinal); + Assert.Contains("warning", stderr.ToString()); + Assert.Contains("error", stderr.ToString()); + Assert.Contains("login", stderr.ToString()); + Assert.DoesNotContain("\u001b", stderr.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void Modes_AreEvaluatedAtWriteTime() + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + var quiet = false; + var stdio = false; + var output = new ShellOutput(() => quiet, () => stdio, () => quiet || stdio, () => stdout, () => stderr); + output.WriteLine(ShellMessageKind.Information, "before"); + quiet = true; + output.WriteLine(ShellMessageKind.Information, "hidden"); + output.WriteLine(ShellMessageKind.Result, "command-level result hidden in machine mode"); + output.WriteResult("result"); + stdio = true; + output.WriteResult("not a protocol response"); + output.WriteLine(ShellMessageKind.RequiredInstruction, "device code"); + Assert.Equal("before" + Environment.NewLine + "result" + Environment.NewLine, stdout.ToString()); + Assert.Equal("device code" + Environment.NewLine, stderr.ToString()); + } + + [Theory] + [InlineData(false, false, true)] + [InlineData(true, false, false)] + [InlineData(false, true, false)] + public void ClearScreen_OnlyTouchesInteractiveTerminal(bool machineMode, bool stdio, bool cleared) + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + var terminal = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(stdout), + }); + var output = new ShellOutput(() => false, () => stdio, () => machineMode, () => stdout, () => stderr, () => terminal); + output.ClearScreen(); + Assert.Equal(cleared, stdout.ToString().Length > 0); + Assert.Empty(stderr.ToString()); + } + + [Fact] + public void Text_PreservesBracesAndExplicitFormatting() + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + var output = CreateOutput(false, false, stdout, stderr); + output.WriteLine(ShellMessageKind.Result, "{\"value\":1}"); + output.Write(ShellMessageKind.Result, "{0}={1}", "value", 2); + Assert.Equal("{\"value\":1}" + Environment.NewLine + "value=2", stdout.ToString()); + } + + [Fact] + public void Markup_PreservesLiteralBracesWithoutFormattingArguments() + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + var output = CreateOutput(false, false, stdout, stderr); + output.Markup(ShellMessageKind.Result, "[red]{\"value\":1}[/]"); + output.MarkupLine(ShellMessageKind.Result, "[red]{\"value\":2}[/]"); + output.MarkupLine(ShellMessageKind.Result, "[red]{0}[/]", "formatted"); + Assert.Equal("{\"value\":1}{\"value\":2}" + Environment.NewLine + "formatted" + Environment.NewLine, stdout.ToString()); + } + + private static ShellOutput CreateOutput(bool quiet, bool stdio, StringWriter stdout, StringWriter stderr, bool machineMode = false) + => new( + () => quiet, + () => stdio, + () => machineMode || quiet || stdio, + () => stdout, + () => stderr, + () => AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Interactive = InteractionSupport.No, + Out = new AnsiConsoleOutput(stdout), + })); +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BatchCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BatchCommand.cs index f5239bbd..b053801d 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BatchCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BatchCommand.cs @@ -104,7 +104,7 @@ private static CommandState Cancel(ShellInterpreter shell) "command-batch-cancelled", "count", count); - return new CommandState { RenderUser = () => ShellInterpreter.WriteLine(message) }; + return new CommandState { RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, message) }; } private static CommandState Status(ShellInterpreter shell) @@ -115,7 +115,7 @@ private static CommandState Status(ShellInterpreter shell) if (batch is null) { root = new JsonObject { ["active"] = false }; - renderUser = () => ShellInterpreter.WriteLine(MessageService.GetString("command-batch-status-inactive")); + renderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-batch-status-inactive")); } else { @@ -164,7 +164,7 @@ void AddDetail(string labelKey, string value) => AddDetail("command-batch-status-target", $"{batch.DatabaseName}/{batch.ContainerName}"); AddDetail("command-batch-status-partition-key", batch.PartitionKeyArgument); AddDetail("command-batch-status-operation-count", batch.Operations.Count.ToString(CultureInfo.InvariantCulture)); - AnsiConsole.Write(details); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, details); if (batch.Operations.Count == 0) { @@ -184,7 +184,7 @@ void AddDetail(string labelKey, string value) => Theme.FormatTableValue(Markup.Escape(operation.Id ?? string.Empty))); } - AnsiConsole.Write(operations); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, operations); } private static CommandState Show(ShellInterpreter shell) @@ -278,7 +278,7 @@ private async Task BeginAsync(ShellInterpreter shell, Cancellation databaseName!, "container", containerName!); - return new CommandState { RenderUser = () => ShellInterpreter.WriteLine(message) }; + return new CommandState { RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, message) }; } private CommandState Add(ShellInterpreter shell, CommandState commandState) @@ -306,7 +306,7 @@ private CommandState Add(ShellInterpreter shell, CommandState commandState) specs.Count, "total", batch.Operations.Count); - return new CommandState { RenderUser = () => ShellInterpreter.WriteLine(message) }; + return new CommandState { RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, message) }; } private async Task ExecuteBatchAsync(ShellInterpreter shell, CancellationToken token) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BatchExecutor.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BatchExecutor.cs index 68040c96..addda5bc 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BatchExecutor.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BatchExecutor.cs @@ -92,7 +92,7 @@ public static async Task ExecuteAsync( { RequestCharge = response.RequestCharge, }; - errorState.RenderUser = () => ShellInterpreter.WriteLine(errorMessage); + errorState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, errorMessage); return errorState; } @@ -103,7 +103,7 @@ internal static CommandState CreateResultState(JsonElement summary, string messa Result = new ShellJson(summary), RequestCharge = requestCharge, }; - state.RenderUser = () => ShellInterpreter.WriteLine(message); + state.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, message); return state; } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BucketCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BucketCommand.cs index 8756412e..843f16d3 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BucketCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BucketCommand.cs @@ -71,15 +71,7 @@ private enum BucketActionKind public bool? Yes { get; init; } public static bool CheckBucket(int bucket) - { - var isValid = bucket >= 0 && bucket <= 5; - if (!isValid) - { - AnsiConsole.MarkupLine(MessageService.GetString("error-invalid_bucket_value", new Dictionary { { "bucket", bucket } })); - } - - return isValid; - } + => bucket >= 0 && bucket <= 5; public override Task ExecuteAsync(ShellInterpreter shell, CommandState commandState, string commandText, CancellationToken token) => shell.State.AcceptAsync(this, shell, token); @@ -138,7 +130,7 @@ private static bool ConfirmWrite(ShellInterpreter shell, bool yes, string summar return true; } - AnsiConsole.MarkupLine(MessageService.GetArgsString(summaryKey, args)); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.RequiredInstruction, MessageService.GetArgsString(summaryKey, args)); return ShellInterpreter.Confirm("command-bucket-confirm"); } @@ -177,7 +169,7 @@ private static CommandState BuildShowResult(ConnectedState state, ThroughputBuck { if (view.Buckets.Count == 0) { - AnsiConsole.MarkupLine(MessageService.GetArgsString("command-bucket-no_limits", "resource", Markup.Escape(view.ResourceName))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetArgsString("command-bucket-no_limits", "resource", Markup.Escape(view.ResourceName))); } else { @@ -191,7 +183,7 @@ private static CommandState BuildShowResult(ConnectedState state, ThroughputBuck Theme.FormatTableValue(bucket.MaxThroughputPercentage.ToString(CultureInfo.InvariantCulture) + "%")); } - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); } WriteClientSelection(clientBucket); @@ -203,11 +195,11 @@ private static void WriteClientSelection(int? clientBucket) { if (clientBucket.HasValue) { - AnsiConsole.MarkupLine(MessageService.GetString("command-bucket-current", new Dictionary { { "bucket", Theme.FormatTableValue(clientBucket.Value.ToString(CultureInfo.InvariantCulture)) } })); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetString("command-bucket-current", new Dictionary { { "bucket", Theme.FormatTableValue(clientBucket.Value.ToString(CultureInfo.InvariantCulture)) } })); } else { - AnsiConsole.MarkupLine(MessageService.GetString("command-bucket-no_bucket")); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetString("command-bucket-no_bucket")); } } @@ -254,18 +246,20 @@ private CommandState RunClientCommand(CosmosClient client) int bucket = int.Parse(this.Action!.Trim(), CultureInfo.InvariantCulture); if (!CheckBucket(bucket)) { - return new CommandState(); + throw new CommandException( + "bucket", + MessageService.GetString("error-invalid_bucket_value", new Dictionary { { "bucket", bucket } })); } if (bucket == 0) { client.ClientOptions.ThroughputBucket = null; - AnsiConsole.MarkupLine(MessageService.GetString("command-bucket-reset_bucket")); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetString("command-bucket-reset_bucket")); } else { client.ClientOptions.ThroughputBucket = bucket; - AnsiConsole.MarkupLine(MessageService.GetString("command-bucket-switched_bucket", new Dictionary { { "bucket", Theme.FormatTableValue(bucket.ToString(CultureInfo.InvariantCulture)) } })); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetString("command-bucket-switched_bucket", new Dictionary { { "bucket", Theme.FormatTableValue(bucket.ToString(CultureInfo.InvariantCulture)) } })); } } else @@ -330,7 +324,7 @@ private async Task ExecuteSetAsync(ConnectedState state, ShellInte return new CommandState { Result = new ShellJson(cancelledDoc.RootElement.Clone()), - RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetString("command-bucket-cancelled")), + RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-bucket-cancelled")), }; } @@ -339,7 +333,7 @@ private async Task ExecuteSetAsync(ConnectedState state, ShellInte var renderSet = setResult.RenderUser; setResult.RenderUser = () => { - ShellInterpreter.WriteLine(MessageService.GetString("command-bucket-set_done")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-bucket-set_done")); renderSet?.Invoke(); }; return setResult; @@ -355,7 +349,7 @@ private async Task ExecuteClearAsync(ConnectedState state, ShellIn return new CommandState { Result = new ShellJson(cancelledDoc.RootElement.Clone()), - RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetString("command-bucket-cancelled")), + RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-bucket-cancelled")), }; } @@ -364,7 +358,7 @@ private async Task ExecuteClearAsync(ConnectedState state, ShellIn var renderClear = clearResult.RenderUser; clearResult.RenderUser = () => { - ShellInterpreter.WriteLine(MessageService.GetString("command-bucket-clear_done")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-bucket-clear_done")); renderClear?.Invoke(); }; return clearResult; diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/CanICommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/CanICommand.cs index 0bdc6c04..8457ef1d 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/CanICommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/CanICommand.cs @@ -214,7 +214,7 @@ internal CommandState Build( private void RenderTable(string action, string? databaseName, string? containerName, string decision, string method, int? statusCode, string? note) { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-can-i-title"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-can-i-title"))); var table = new Table(); table.AddColumns(string.Empty, string.Empty); @@ -233,11 +233,11 @@ private void RenderTable(string action, string? databaseName, string? containerN table.AddRow(MessageService.GetString("command-can-i-status"), Theme.FormatTableValue(code.ToString())); } - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); if (!string.IsNullOrEmpty(note)) { - AnsiConsole.MarkupLine(Theme.FormatMuted(note)); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(note)); } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/CdCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/CdCommand.cs index 28143ac9..b86de294 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/CdCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/CdCommand.cs @@ -83,7 +83,7 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co SetState(shell, new ConnectedState(connectedState.Client, connectedState.ArmContext)); if (!this.Quiet) { - ShellInterpreter.WriteLine(MessageService.GetString("command-cd-changed_to_connected_state")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetString("command-cd-changed_to_connected_state")); } CosmosCompleteCommand.ClearContainers(); @@ -100,7 +100,7 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co SetState(shell, new DatabaseState(targetDatabase, connectedState.Client, connectedState.ArmContext)); if (!this.Quiet) { - ShellInterpreter.WriteLine(MessageService.GetString("command-cd-changed_to_db", new Dictionary { { "db", targetDatabase } })); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetString("command-cd-changed_to_db", new Dictionary { { "db", targetDatabase } })); } CosmosCompleteCommand.ClearContainers(); @@ -112,7 +112,7 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co SetState(shell, new ContainerState(targetContainer, targetDatabase, connectedState.Client, connectedState.ArmContext)); if (!this.Quiet) { - ShellInterpreter.WriteLine(MessageService.GetString("command-cd-changed_to_container", new Dictionary { { "container", targetContainer } })); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetString("command-cd-changed_to_container", new Dictionary { { "container", targetContainer } })); } CosmosCompleteCommand.ClearContainers(); @@ -132,7 +132,7 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co SetState(shell, new ContainerState(targetContainer, dbName, connectedState.Client, connectedState.ArmContext)); if (!this.Quiet) { - ShellInterpreter.WriteLine(MessageService.GetString("command-cd-changed_to_container", new Dictionary { { "container", targetContainer } })); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetString("command-cd-changed_to_container", new Dictionary { { "container", targetContainer } })); } CosmosCompleteCommand.ClearContainers(); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ClsCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ClsCommand.cs index fd808511..f13bbcbb 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ClsCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ClsCommand.cs @@ -18,7 +18,7 @@ public override Task ExecuteAsync(ShellInterpreter shell, CommandS { try { - AnsiConsole.Clear(); + shell.Output.ClearScreen(); } catch (IOException) { diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConflictCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConflictCommand.cs index 98170d1b..8022cf30 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConflictCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConflictCommand.cs @@ -235,7 +235,7 @@ private async Task SetAsync(ConnectedState state, string databaseN } var view = await CosmosResourceFacade.ReplaceConflictResolutionPolicyAsync(state, databaseName, containerName, update, token); - ShellInterpreter.WriteLine(MessageService.GetString("command-conflict-updated")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetString("command-conflict-updated")); return BuildResult(containerName, view); } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConnectCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConnectCommand.cs index f75980d6..32e7b64c 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConnectCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConnectCommand.cs @@ -75,7 +75,7 @@ public async override Task ExecuteAsync(ShellInterpreter shell, Co var previousEndpoint = cs.Client.Endpoint.Host; if (!isMachineMode) { - AnsiConsole.MarkupLine(MessageService.GetArgsString("command-connect-switching", "endpoint", previousEndpoint)); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString("command-connect-switching", "endpoint", previousEndpoint)); } } @@ -152,8 +152,8 @@ internal static bool TryGetPrincipalIdFromRbacException(Exception e, out string? internal static void AskForRBacPermissions(string principalId, string permission) { - AnsiConsole.Markup(Theme.FormatError(MessageService.GetString("error")) + " "); - ShellInterpreter.WriteLine(MessageService.GetArgsString("command-connect-rbac-error", "id", principalId, "permission", permission)); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Error, Theme.FormatError(MessageService.GetString("error")) + " "); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, MessageService.GetArgsString("command-connect-rbac-error", "id", principalId, "permission", permission)); } /// @@ -164,7 +164,7 @@ internal static void AskForRBacPermissions(string principalId, string permission /// internal static void PrintConnectUsageHint(ShellInterpreter shell) { - AnsiConsole.MarkupLine(Markup.Escape(MessageService.GetString("command-connect-not_connected-usage-header"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Markup.Escape(MessageService.GetString("command-connect-not_connected-usage-header"))); if (shell.App.Commands.TryGetValue("connect", out var factory)) { @@ -184,17 +184,17 @@ internal static void PrintConnectUsageHint(ShellInterpreter shell) } var highlighted = shell.BuildHighlightedMarkup(example); - AnsiConsole.MarkupLine($" {highlighted}"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, $" {highlighted}"); if (!string.IsNullOrWhiteSpace(description)) { - AnsiConsole.MarkupLine($" {Theme.FormatMuted(description)}"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, $" {Theme.FormatMuted(description)}"); } shown++; } } - AnsiConsole.MarkupLine(Markup.Escape(MessageService.GetString("command-connect-not_connected-usage-footer"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Markup.Escape(MessageService.GetString("command-connect-not_connected-usage-footer"))); } private static async Task PrintConnectionInfoAsync(ShellInterpreter shell, CommandState commandState, CancellationToken token) @@ -209,7 +209,7 @@ private static async Task PrintConnectionInfoAsync(ShellInterprete commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(notConnectedJson)); commandState.RenderUser = () => { - AnsiConsole.MarkupLine(MessageService.GetString("command-connect-not_connected")); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-connect-not_connected")); PrintConnectUsageHint(shell); }; return commandState; @@ -258,7 +258,7 @@ private static async Task PrintConnectionInfoAsync(ShellInterprete }; commandState.RenderUser = () => { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-connect-info-title"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-connect-info-title"))); var table = new Table(); table.AddColumns(string.Empty, string.Empty); @@ -282,7 +282,7 @@ private static async Task PrintConnectionInfoAsync(ShellInterprete // Show current navigation state table.AddRow(MessageService.GetString("command-connect-info-location"), Theme.ConnectedStatePromt(currentLocation)); - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); }; return commandState; } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/DirCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/DirCommand.cs index 47780690..1a19f4b7 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/DirCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/DirCommand.cs @@ -149,11 +149,11 @@ private void RenderEntries(List entries) // Simple list mode: just show the name if (entry.IsDirectory) { - AnsiConsole.MarkupLine(Theme.FormatDirectory(entry.Name + "/")); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatDirectory(entry.Name + "/")); } else { - AnsiConsole.WriteLine(entry.Name); + ShellInterpreter.Instance.Output.RenderLine(ShellMessageKind.Result, entry.Name); } } else @@ -161,19 +161,19 @@ private void RenderEntries(List entries) // Detailed mode: show date, size, and name if (entry.IsDirectory) { - AnsiConsole.MarkupLine(Theme.FormatDirectory(entry.Name + "/")); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatDirectory(entry.Name + "/")); } else { var sizeStr = FormatFileSize(entry.Size ?? 0); - AnsiConsole.MarkupLine($"{Theme.FormatMuted($"{entry.LastModified:yyyy-MM-dd HH:mm}")} {Theme.FormatTableValue(sizeStr.PadLeft(10))} {Markup.Escape(entry.Name)}"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, $"{Theme.FormatMuted($"{entry.LastModified:yyyy-MM-dd HH:mm}")} {Theme.FormatTableValue(sizeStr.PadLeft(10))} {Markup.Escape(entry.Name)}"); } } } if (!this.ListOnly) { - AnsiConsole.MarkupLine(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetArgsString( "command-dir-summary", "fileCount", entries.Count(e => !e.IsDirectory), diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/DisconnectCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/DisconnectCommand.cs index 97ecd38b..aabbcfd4 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/DisconnectCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/DisconnectCommand.cs @@ -30,7 +30,7 @@ public override Task ExecuteAsync(ShellInterpreter shell, CommandS ["endpoint"] = endpoint, }; commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(jsonResult)); - commandState.RenderUser = () => AnsiConsole.MarkupLine(MessageService.GetArgsString("command-disconnect-success", "endpoint", endpoint)); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetArgsString("command-disconnect-success", "endpoint", endpoint)); } else { @@ -40,7 +40,7 @@ public override Task ExecuteAsync(ShellInterpreter shell, CommandS ["disconnected"] = false, }; commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(jsonResult)); - commandState.RenderUser = () => AnsiConsole.MarkupLine(MessageService.GetString("command-disconnect-not_connected")); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-disconnect-not_connected")); } return Task.FromResult(commandState); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/DoctorCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/DoctorCommand.cs index 46cc7cb0..22cf0d4b 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/DoctorCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/DoctorCommand.cs @@ -478,7 +478,7 @@ internal CommandState CreateResult(List checks, CommandState comman if (this.IncludeIdentity) { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(Message("who-heading"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(Message("who-heading"))); } var grid = new Grid(); @@ -506,9 +506,9 @@ internal CommandState CreateResult(List checks, CommandState comman } } - AnsiConsole.Write(grid); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine($"{Theme.FormatSectionHeader(Message("summary"))}: {summary.pass} {FormatStatus("PASS")} | {summary.warn} {FormatStatus("WARN")} | {summary.fail} {FormatStatus("FAIL")} | {summary.skip} {FormatStatus("SKIP")} | {durationMs.ToString(CultureInfo.InvariantCulture)} ms | {FormatCharge(result.RequestCharge)} RU"); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, grid); + ShellInterpreter.Instance.Output.RenderLine(ShellMessageKind.Result); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, $"{Theme.FormatSectionHeader(Message("summary"))}: {summary.pass} {FormatStatus("PASS")} | {summary.warn} {FormatStatus("WARN")} | {summary.fail} {FormatStatus("FAIL")} | {summary.skip} {FormatStatus("SKIP")} | {durationMs.ToString(CultureInfo.InvariantCulture)} ms | {FormatCharge(result.RequestCharge)} RU"); }; return result; } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/EditCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/EditCommand.cs index 59db19e7..559c0561 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/EditCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/EditCommand.cs @@ -34,8 +34,7 @@ public override Task ExecuteAsync(ShellInterpreter shell, CommandS if (Console.IsInputRedirected || !string.IsNullOrEmpty(shell.CurrentScriptFileName)) { var message = MessageService.GetString("command-edit-not-interactive"); - AnsiConsole.MarkupLine(Theme.FormatError(message)); - return Task.FromResult(new ErrorCommandState(new CommandException("edit", message))); + throw new CommandException("edit", message); } var path = this.FilePath; @@ -56,19 +55,17 @@ public override Task ExecuteAsync(ShellInterpreter shell, CommandS catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException) { var message = MessageService.GetArgsString("command-edit-create-failed", "path", path, "message", ex.Message); - AnsiConsole.MarkupLine(Theme.FormatError(message)); - return Task.FromResult(new ErrorCommandState(new CommandException("edit", message, ex))); + throw new CommandException("edit", message, ex); } var editor = ExternalEditor.Resolve(null); if (editor is null) { var message = MessageService.GetString("command-edit-no-editor"); - AnsiConsole.MarkupLine(Theme.FormatError(message)); - return Task.FromResult(new ErrorCommandState(new CommandException("edit", message))); + throw new CommandException("edit", message); } - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, Theme.FormatMuted(MessageService.GetArgsString( "command-edit-launching", "path", path, @@ -97,8 +94,7 @@ public override Task ExecuteAsync(ShellInterpreter shell, CommandS path, "message", ex.Message); - AnsiConsole.MarkupLine(Theme.FormatError(message)); - return Task.FromResult(new ErrorCommandState(new CommandException("edit", message, ex))); + throw new CommandException("edit", message, ex); } if (exitCode != 0) @@ -109,12 +105,11 @@ public override Task ExecuteAsync(ShellInterpreter shell, CommandS editor.DisplayName, "code", exitCode); - AnsiConsole.MarkupLine(Theme.FormatWarning(message)); - return Task.FromResult(new ErrorCommandState(new CommandException("edit", message))); + throw new CommandException("edit", message); } commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "file", path, edited = true })); - commandState.RenderUser = () => AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetArgsString("command-edit-saved", "path", path))); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetArgsString("command-edit-saved", "path", path))); return Task.FromResult(commandState); } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ExportCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ExportCommand.cs index a2195c44..407c1e36 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ExportCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ExportCommand.cs @@ -94,7 +94,7 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co var (count, charge) = await ExecuteExportAsync(container, query, max, format, filePath, this.Force == true, token); - ShellInterpreter.WriteLine(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-export-success", "count", count, diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/FtabCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/FtabCommand.cs index 3ab2984a..61552d20 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/FtabCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/FtabCommand.cs @@ -409,7 +409,7 @@ private static void RenderSpectreTable(IReadOnlyList headers, IReadOnlyL table.AddRow(row.Select((cell, index) => FormatStyledCell(headers[index], cell, colorizeRules)).ToArray()); } - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); } private static string RenderPlainText(IReadOnlyList headers, IReadOnlyList> rows) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/HelpCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/HelpCommand.cs index 3ab3971f..f83ca567 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/HelpCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/HelpCommand.cs @@ -65,9 +65,7 @@ public static CommandState PrintCommandHelp(string cmdStr, CommandRunner app, bo } // Neither command nor statement - AnsiConsole.Markup(Theme.FormatError(MessageService.GetString("error")) + " "); - ShellInterpreter.WriteLine(MessageService.GetString("error-command-not-found", MessageService.Args("command", cmdStr))); - return new ErrorCommandState(new CommandException("help", cmdStr + " not found.")); + throw new CommandException("help", MessageService.GetString("error-command-not-found", MessageService.Args("command", cmdStr))); } if (cmd == null) @@ -82,69 +80,69 @@ public static CommandState PrintCommandHelp(string cmdStr, CommandRunner app, bo // Plain output (no colors, no panels) if (!string.IsNullOrEmpty(cmd.Description)) { - ShellInterpreter.WriteLine(cmd.Description); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, cmd.Description); } - ShellInterpreter.WriteLine(); - ShellInterpreter.WriteLine(MessageService.GetString("help-usage", new System.Collections.Generic.Dictionary { ["command"] = cmd.CommandName }) + " " + BuildPlainUsage(cmd)); - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("help-usage", new System.Collections.Generic.Dictionary { ["command"] = cmd.CommandName }) + " " + BuildPlainUsage(cmd)); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); if (cmd.Aliases.Count > 0) { - ShellInterpreter.WriteLine($"{MessageService.GetString("help-aliases")} {string.Join(", ", cmd.Aliases)}"); - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, $"{MessageService.GetString("help-aliases")} {string.Join(", ", cmd.Aliases)}"); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); } if (cmd.Parameters.Count > 0) { - ShellInterpreter.WriteLine(MessageService.GetString("help-arguments")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("help-arguments")); foreach (var p in cmd.Parameters) { var name = p.Name.FirstOrDefault() ?? string.Empty; - ShellInterpreter.Write(" "); - ShellInterpreter.Write(p.IsRequired ? name : $"[{name}]"); - ShellInterpreter.Write(" "); + ShellInterpreter.Instance.Output.Write(ShellMessageKind.Result, " "); + ShellInterpreter.Instance.Output.Write(ShellMessageKind.Result, p.IsRequired ? name : $"[{name}]"); + ShellInterpreter.Instance.Output.Write(ShellMessageKind.Result, " "); var desc = p.GetDescription(cmd.CommandName) ?? string.Empty; if (!p.IsRequired) { - ShellInterpreter.Write($"{MessageService.GetString("help-optional")} "); + ShellInterpreter.Instance.Output.Write(ShellMessageKind.Result, $"{MessageService.GetString("help-optional")} "); } - ShellInterpreter.WriteLine(desc); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, desc); } - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); } if (cmd.Options.Count > 0) { - ShellInterpreter.WriteLine(MessageService.GetString("help-options")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("help-options")); foreach (var opt in cmd.Options) { var names = string.Join(", ", opt.Name.Select(n => "-" + n)); var desc = opt.GetDescription(cmd.CommandName) ?? string.Empty; - ShellInterpreter.WriteLine($" {names} {desc}"); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, $" {names} {desc}"); } - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); } // Use shared examples collection to avoid shadowing var plainExamples = cmd.ExamplesWithDescriptions; if (plainExamples.Count > 0) { - ShellInterpreter.WriteLine(MessageService.GetString("help-examples")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("help-examples")); for (int i = 0; i < plainExamples.Count; i++) { var (ex, desc) = plainExamples[i]; if (!string.IsNullOrEmpty(desc)) { - ShellInterpreter.WriteLine($" {i + 1}. {desc}"); - ShellInterpreter.WriteLine($" {ex}"); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, $" {i + 1}. {desc}"); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, $" {ex}"); } else { - ShellInterpreter.WriteLine($" {i + 1}. {ex}"); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, $" {i + 1}. {ex}"); } } } @@ -154,31 +152,31 @@ public static CommandState PrintCommandHelp(string cmdStr, CommandRunner app, bo // Styled output if (!string.IsNullOrEmpty(cmd.Description)) { - AnsiConsole.MarkupLine($"{INDENT}{Theme.FormatHelpHeader(cmd.Description)}"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, $"{INDENT}{Theme.FormatHelpHeader(cmd.Description)}"); } if (cmd.Aliases.Count > 0) { - AnsiConsole.MarkupLine($"{INDENT}[dim]{Markup.Escape(MessageService.GetString("help-aliases"))} {Markup.Escape(string.Join(", ", cmd.Aliases))}[/]"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, $"{INDENT}[dim]{Markup.Escape(MessageService.GetString("help-aliases"))} {Markup.Escape(string.Join(", ", cmd.Aliases))}[/]"); } - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); WriteSectionHeader(MessageService.GetString("help-usage-heading")); - AnsiConsole.Markup(INDENT + Theme.FormatCommand(cmd.CommandName) + " "); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, INDENT + Theme.FormatCommand(cmd.CommandName) + " "); } if (!plain && cmd?.Options != null) { foreach (var p in cmd.Options) { - AnsiConsole.Markup(INDENT + "[[" + Theme.FormatHelpName("-" + (p.Name.FirstOrDefault() ?? string.Empty))); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, INDENT + "[[" + Theme.FormatHelpName("-" + (p.Name.FirstOrDefault() ?? string.Empty))); if (!p.PropertyInfo.PropertyType.IsAssignableFrom(typeof(bool))) { - AnsiConsole.Markup($" [dim]{MessageService.GetString("help-arg")}[/]"); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, $" [dim]{MessageService.GetString("help-arg")}[/]"); } - AnsiConsole.Markup("]] "); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "]] "); } } @@ -194,16 +192,16 @@ public static CommandState PrintCommandHelp(string cmdStr, CommandRunner app, bo if (p.IsRequired) { - AnsiConsole.Markup(INDENT + Theme.FormatHelpName(name) + " "); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, INDENT + Theme.FormatHelpName(name) + " "); } else { - AnsiConsole.Markup(INDENT + "[[" + Theme.FormatHelpName(name) + "]] "); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, INDENT + "[[" + Theme.FormatHelpName(name) + "]] "); } } - ShellInterpreter.WriteLine(); - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); if (cmd.Parameters.Count > 0) { @@ -241,8 +239,8 @@ public static CommandState PrintCommandHelp(string cmdStr, CommandRunner app, bo table.AddRow(INDENT + nameDisplay, descDisplay); } - AnsiConsole.Write(table); - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); } } @@ -278,24 +276,24 @@ public static CommandState PrintCommandHelp(string cmdStr, CommandRunner app, bo table.AddRow(INDENT + Theme.FormatHelpName(sb.ToString()), descDisplay); } - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); } var examples = cmd?.ExamplesWithDescriptions; if (!plain && examples != null && examples.Count > 0) { - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); WriteSectionHeader(MessageService.GetString("help-examples-heading")); for (int i = 0; i < examples.Count; i++) { var (example, description) = examples[i]; if (!string.IsNullOrWhiteSpace(description)) { - AnsiConsole.MarkupLine(INDENT + $"{Theme.FormatHelpAccent("\u25b6")} {i + 1}. {Theme.FormatHelpDescription(description)}"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, INDENT + $"{Theme.FormatHelpAccent("\u25b6")} {i + 1}. {Theme.FormatHelpDescription(description)}"); } else { - AnsiConsole.MarkupLine(INDENT + $"{Theme.FormatHelpAccent("\u25b6")} {i + 1}."); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, INDENT + $"{Theme.FormatHelpAccent("\u25b6")} {i + 1}."); } var parser = new StatementParser(example); @@ -328,11 +326,11 @@ public static CommandState PrintCommandHelp(string cmdStr, CommandRunner app, bo Border = BoxBorder.None, Padding = new Padding(4, 0, 0, 0), }; - AnsiConsole.Write(panel); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, panel); if (i < examples.Count - 1) { - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); } } } @@ -496,8 +494,8 @@ private static void PrintCommandCategory(string categoryName, List s.Name)) { - ShellInterpreter.WriteLine($" {s.Name}"); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, $" {s.Name}"); if (!string.IsNullOrWhiteSpace(s.Description)) { - ShellInterpreter.WriteLine($" {s.Description}"); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, $" {s.Description}"); } - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); } } else @@ -711,8 +709,8 @@ private static void PrintStatementHelps(bool plain) BorderStyle = Theme.GetHelpBorderStyle(), Padding = new Padding(1, 0, 1, 0), }; - AnsiConsole.Write(stmtPanel); - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, stmtPanel); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); var table = new Table() .Border(TableBorder.None) @@ -729,8 +727,8 @@ private static void PrintStatementHelps(bool plain) table.AddRow(INDENT + Theme.FormatKeyword(s.Name), desc); } - AnsiConsole.Write(table); - AnsiConsole.MarkupLine(string.Empty); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, string.Empty); } } @@ -783,7 +781,7 @@ private static void WriteSectionHeader(string title) Justification = Justify.Left, Style = Theme.GetMutedStyle(), }; - AnsiConsole.Write(rule); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, rule); } private static string BuildPlainUsage(CommandFactory cmd) @@ -827,21 +825,21 @@ private static void PrintPlainCategory(string title, List comman return; } - ShellInterpreter.WriteLine(title + ":"); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, title + ":"); foreach (var c in commands) { - ShellInterpreter.WriteLine(" " + c.CommandName + " - " + (c.Description ?? string.Empty)); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, " " + c.CommandName + " - " + (c.Description ?? string.Empty)); } - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); } private void RenderCommandList(CommandRunner app) { if (this.Plain) { - ShellInterpreter.WriteLine(MessageService.GetString("help-available-commands")); - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("help-available-commands")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); } else { @@ -852,8 +850,8 @@ private void RenderCommandList(CommandRunner app) BorderStyle = Theme.GetHelpBorderStyle(), Padding = new Padding(1, 0, 1, 0), }; - AnsiConsole.Write(headerPanel); - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, headerPanel); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); } // Group commands by category (basic heuristic based on name patterns) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ImportCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ImportCommand.cs index 0bf1a244..af1cbc81 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ImportCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ImportCommand.cs @@ -485,39 +485,42 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co var (successCount, failCount, charge) = await ExecuteImportAsync(filePath, format, mode, container, continueOnError, dryRun, partitionKeySegments, token); - if (dryRun) + if (!shell.IsMachineMode) { - ShellInterpreter.WriteLine(MessageService.GetArgsString( - "command-import-dry-run-success", - "count", - successCount)); - } - else if (failCount == 0) - { - ShellInterpreter.WriteLine(MessageService.GetArgsString( - "command-import-success", - "count", - successCount, - "charge", - charge.ToString("F2", CultureInfo.InvariantCulture))); - } - else if (successCount > 0) - { - ShellInterpreter.WriteLine(MessageService.GetArgsString( - "command-import-success-partial", - "success", - successCount, - "failed", - failCount, - "charge", - charge.ToString("F2", CultureInfo.InvariantCulture))); - } - else - { - ShellInterpreter.WriteLine(MessageService.GetArgsString( - "command-import-all-failed", - "count", - failCount)); + if (dryRun) + { + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString( + "command-import-dry-run-success", + "count", + successCount)); + } + else if (failCount == 0) + { + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString( + "command-import-success", + "count", + successCount, + "charge", + charge.ToString("F2", CultureInfo.InvariantCulture))); + } + else if (successCount > 0) + { + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Warning, MessageService.GetArgsString( + "command-import-success-partial", + "success", + successCount, + "failed", + failCount, + "charge", + charge.ToString("F2", CultureInfo.InvariantCulture))); + } + else + { + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, MessageService.GetArgsString( + "command-import-all-failed", + "count", + failCount)); + } } if (failCount > 0) @@ -629,12 +632,16 @@ private static async Task ResolveFormatAsync(string filePath, Impo else { failed++; - ShellInterpreter.WriteLine(MessageService.GetArgsString( - "command-import-error-item_status", - "line", - lineNumber, - "status", - response.StatusCode.ToString())); + if (!ShellInterpreter.Instance.IsMachineMode) + { + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, MessageService.GetArgsString( + "command-import-error-item_status", + "line", + lineNumber, + "status", + response.StatusCode.ToString())); + } + if (!continueOnError) { break; @@ -645,14 +652,18 @@ private static async Task ResolveFormatAsync(string filePath, Impo { failed++; charge += ce.RequestCharge; - ShellInterpreter.WriteLine(MessageService.GetArgsString( - "command-import-error-item_failed", - "line", - lineNumber, - "status", - ce.StatusCode.ToString(), - "message", - CommandException.GetDisplayMessage(ce))); + if (!ShellInterpreter.Instance.IsMachineMode) + { + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, MessageService.GetArgsString( + "command-import-error-item_failed", + "line", + lineNumber, + "status", + ce.StatusCode.ToString(), + "message", + CommandException.GetDisplayMessage(ce))); + } + if (!continueOnError) { break; diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/IndexCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/IndexCommand.cs index 27933f67..0177ec69 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/IndexCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/IndexCommand.cs @@ -418,7 +418,7 @@ private async Task WritePolicyAsync(ConnectedState state, string d throw new CommandException("index", MessageService.GetString("command-index-error-invalid_policy"), ex); } - ShellInterpreter.WriteLine(MessageService.GetString("command-index-updated")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetString("command-index-updated")); return BuildResult(updatedJson); } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/InfoCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/InfoCommand.cs index 11a78de3..fe493f69 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/InfoCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/InfoCommand.cs @@ -196,17 +196,17 @@ private static void AskForRBacPermissions(string principalId, string request, st var line = lines[i]; if (i == 0) { - AnsiConsole.Markup(indent + Theme.FormatError(MessageService.GetString("error")) + " "); - ShellInterpreter.WriteLine(line); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, indent + Theme.FormatError(MessageService.GetString("error")) + " "); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, line); } else if (line.Length == 0) { - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); } else { - AnsiConsole.Markup(indent); - ShellInterpreter.WriteLine(line); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, indent); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, line); } } } @@ -215,9 +215,9 @@ private static async Task WriteAccountDatabaseBreakdownAsync(ConnectedState stat { if (renderOutput) { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-stats-account-databases-heading"))); - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-stats-account-detailed-cost-note"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-stats-account-databases-heading"))); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "\t"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetString("command-stats-account-detailed-cost-note"))); } long totalContainers = 0; @@ -277,7 +277,7 @@ private static async Task WriteAccountDatabaseBreakdownAsync(ConnectedState stat totalsTable.AddRow(MessageService.GetString("command-stats-account-label-total-containers"), Theme.FormatTableValue(totalContainers.ToString(CultureInfo.InvariantCulture))); totalsTable.AddRow(MessageService.GetString("command-stats-account-label-total-documents"), Theme.FormatTableValue(FormatCount(totalDocuments))); totalsTable.AddRow(MessageService.GetString("command-stats-account-label-total-size"), Theme.FormatTableValue(FormatSize(totalSizeKb))); - AnsiConsole.Write(totalsTable); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, totalsTable); if (rows.Count == 0) { @@ -298,7 +298,7 @@ private static async Task WriteAccountDatabaseBreakdownAsync(ConnectedState stat Theme.FormatTableValue(FormatSize(row.SizeKb))); } - AnsiConsole.Write(databaseTable); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, databaseTable); } internal static void AddSessionUsage(ShellInterpreter shell, Dictionary mcpTable, bool renderOutput) @@ -324,7 +324,7 @@ internal static void AddSessionUsage(ShellInterpreter shell, Dictionary ReadContainerUsageAsync(Container container, CancellationToken token) @@ -396,7 +396,7 @@ private static async Task WriteDatabaseThroughputAsync(Database database, Dictio return; } - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-stats-throughput-heading"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-stats-throughput-heading"))); if (throughputError is not null) { @@ -406,14 +406,14 @@ private static async Task WriteDatabaseThroughputAsync(Database database, Dictio } else { - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(Theme.FormatError(throughputError)); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "\t"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatError(throughputError)); } } else if (serverless) { - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-settings-scale-serverless"))); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "\t"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetString("command-settings-scale-serverless"))); } else if (min.HasValue || max.HasValue) { @@ -430,12 +430,12 @@ private static async Task WriteDatabaseThroughputAsync(Database database, Dictio table.AddRow(MessageService.GetString("command-stats-throughput-min"), Theme.FormatTableValue(renderedMinValue.ToString(CultureInfo.InvariantCulture))); } - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); } else { - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-stats-database-shared-throughput-none"))); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "\t"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetString("command-stats-database-shared-throughput-none"))); } } @@ -443,9 +443,9 @@ private static async Task WriteDatabaseThroughputAsync(Database database, Dictio { if (renderOutput) { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-stats-partitions-heading"))); - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-stats-partitions-cost-note"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-stats-partitions-heading"))); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "\t"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetString("command-stats-partitions-cost-note"))); } var ranges = await container.GetFeedRangesAsync(token); @@ -488,14 +488,14 @@ private static async Task WriteDatabaseThroughputAsync(Database database, Dictio if (table is not null) { - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); } if (renderOutput && total > 0 && counts.Count > 1) { double largestShare = (double)counts.Max() / total * 100; - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "\t"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetArgsString( "command-stats-partitions-skew", "percent", string.Create(CultureInfo.InvariantCulture, $"{largestShare:0.#}")))); @@ -508,9 +508,9 @@ private static async Task WriteDatabaseThroughputAsync(Database database, Dictio { if (renderOutput) { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-stats-detailed-heading"))); - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-stats-detailed-cost-note"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-stats-detailed-heading"))); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "\t"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetString("command-stats-detailed-cost-note"))); } var result = new List>(); @@ -518,8 +518,8 @@ private static async Task WriteDatabaseThroughputAsync(Database database, Dictio { if (renderOutput) { - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-stats-na"))); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "\t"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetString("command-stats-na"))); } return result; @@ -583,7 +583,7 @@ private static async Task WriteDatabaseThroughputAsync(Database database, Dictio if (table is not null) { - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); } return result; @@ -716,7 +716,7 @@ private async Task ShowContainerSettingsAsync(ShellInterpreter she // Scale section - fail gracefully if it cannot be read if (renderOutput) { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-settings-scale-heading"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-settings-scale-heading"))); } switch (view.Throughput) @@ -726,8 +726,8 @@ private async Task ShowContainerSettingsAsync(ShellInterpreter she var maxDisplay = view.MaxThroughput?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? MessageService.GetString("command-settings-na"); if (renderOutput) { - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(MessageService.GetArgsString("command-settings-scale-usage", "min", minDisplay, "max", maxDisplay)); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "\t"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetArgsString("command-settings-scale-usage", "min", minDisplay, "max", maxDisplay)); } if (view.MinThroughput.HasValue) @@ -744,16 +744,16 @@ private async Task ShowContainerSettingsAsync(ShellInterpreter she case ThroughputAvailability.NotConfigured: if (renderOutput) { - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-settings-na"))); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "\t"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetString("command-settings-na"))); } break; case ThroughputAvailability.Serverless: if (renderOutput) { - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-settings-scale-serverless"))); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "\t"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetString("command-settings-scale-serverless"))); } break; @@ -766,8 +766,8 @@ private async Task ShowContainerSettingsAsync(ShellInterpreter she } else { - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(Theme.FormatError(view.ThroughputErrorMessage ?? string.Empty)); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "\t"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatError(view.ThroughputErrorMessage ?? string.Empty)); } } @@ -776,7 +776,7 @@ private async Task ShowContainerSettingsAsync(ShellInterpreter she if (renderOutput) { - AnsiConsole.MarkupLine(string.Empty); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, string.Empty); } mcpTable["id"] = view.ContainerName; @@ -804,7 +804,7 @@ private async Task ShowContainerSettingsAsync(ShellInterpreter she Table? table = null; if (renderOutput) { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-settings-title"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-settings-title"))); table = new Table(); table.AddColumns(string.Empty, string.Empty); table.AddRow(MessageService.GetString("command-settings-ttl-label"), Theme.FormatTableValue(ttl)); @@ -827,13 +827,13 @@ private async Task ShowContainerSettingsAsync(ShellInterpreter she { table.AddRow(MessageService.GetString("command-settings-partition-key-label"), Theme.FormatTableValue(string.Join(',', view.PartitionKeyPaths))); table.HideHeaders(); - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); } // Full Text Policy section - show N/A when policy is unset if (renderOutput) { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-settings-fulltext-title"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-settings-fulltext-title"))); } if (view.FullTextPolicy is { } fullText) @@ -872,19 +872,19 @@ private async Task ShowContainerSettingsAsync(ShellInterpreter she if (fullTextTable is not null) { fullTextTable.HideHeaders(); - AnsiConsole.Write(fullTextTable); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, fullTextTable); } } else if (renderOutput) { - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-settings-na"))); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "\t"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetString("command-settings-na"))); } // Indexing Policy section - compact summary; use 'index show' for the full policy if (renderOutput) { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-settings-indexing-title"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-settings-indexing-title"))); } if (view.IndexingPolicy is { } indexing) @@ -931,7 +931,7 @@ private async Task ShowContainerSettingsAsync(ShellInterpreter she } indexingTable.HideHeaders(); - AnsiConsole.Write(indexingTable); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, indexingTable); } mcpTable["indexingPolicy"] = new Dictionary @@ -947,8 +947,8 @@ private async Task ShowContainerSettingsAsync(ShellInterpreter she } else if (renderOutput) { - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-settings-na"))); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "\t"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetString("command-settings-na"))); } // Usage section - document count and storage size @@ -965,7 +965,7 @@ private async Task ShowContainerSettingsAsync(ShellInterpreter she if (renderOutput) { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-settings-usage-heading"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-settings-usage-heading"))); var usageTable = new Table(); usageTable.AddColumns(string.Empty, string.Empty); @@ -978,7 +978,7 @@ private async Task ShowContainerSettingsAsync(ShellInterpreter she } usageTable.AddRow(MessageService.GetString("command-stats-label-total-size"), Theme.FormatTableValue(FormatSize(usage.TotalSizeKb))); - AnsiConsole.Write(usageTable); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, usageTable); } if (this.Partitions) @@ -1043,7 +1043,7 @@ private async Task ShowDatabaseSettingsAsync(ShellInterpreter shel if (renderOutput) { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-stats-database-heading"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-stats-database-heading"))); var table = new Table(); table.AddColumns(string.Empty, string.Empty); @@ -1052,14 +1052,14 @@ private async Task ShowDatabaseSettingsAsync(ShellInterpreter shel table.AddRow(MessageService.GetString("command-stats-database-label-container-count"), Theme.FormatTableValue(rows.Count.ToString(CultureInfo.InvariantCulture))); table.AddRow(MessageService.GetString("command-stats-database-label-total-documents"), Theme.FormatTableValue(FormatCount(totalDocuments))); table.AddRow(MessageService.GetString("command-stats-database-label-total-size"), Theme.FormatTableValue(FormatSize(totalSizeKb))); - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); } await WriteDatabaseThroughputAsync(database, mcpTable, renderOutput, token); if (renderOutput && this.Detailed && rows.Count > 0) { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-stats-containers-heading"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-stats-containers-heading"))); var containerTable = new Table(); containerTable.AddColumn(MessageService.GetString("command-stats-containers-col-name")); containerTable.AddColumn(MessageService.GetString("command-stats-containers-col-count")); @@ -1072,7 +1072,7 @@ private async Task ShowDatabaseSettingsAsync(ShellInterpreter shel Theme.FormatTableValue(FormatSize(row.SizeKb))); } - AnsiConsole.Write(containerTable); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, containerTable); } if (this.Detailed) @@ -1099,7 +1099,7 @@ private async Task PrintOverviewAsync(ShellInterpreter shell, Conn if (renderOutput) { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-settings-overview"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-settings-overview"))); var table = new Table(); table.AddColumns(string.Empty, string.Empty, string.Empty, string.Empty); @@ -1107,7 +1107,7 @@ private async Task PrintOverviewAsync(ShellInterpreter shell, Conn table.AddRow(MessageService.GetString("command-settings-uri"), Theme.FormatTableValue(client.Endpoint.ToString()), MessageService.GetString("command-settings-write_locations"), Theme.FormatTableValue(string.Join(", ", acc.WritableRegions.Select(location => location.Name)))); table.AddRow(MessageService.GetString("command-stats-account-label-database-count"), Theme.FormatTableValue(databaseNames.Count.ToString(CultureInfo.InvariantCulture)), string.Empty, string.Empty); table.HideHeaders(); - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); } var mcpTable = new Dictionary diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ListCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ListCommand.cs index 6b7ae64c..eb6635c9 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ListCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ListCommand.cs @@ -113,10 +113,10 @@ async Task IStateVisitor.VisitConn { foreach (var name in list) { - AnsiConsole.MarkupLine(Theme.DatabaseNamePromt(name)); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.DatabaseNamePromt(name)); } - AnsiConsole.MarkupLine(MessageService.GetString("command-ls-found_databases", new Dictionary + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-ls-found_databases", new Dictionary { { "count", list.Count }, { "display", Theme.FormatTableValue(list.Count.ToString()) }, @@ -124,7 +124,7 @@ async Task IStateVisitor.VisitConn if (completionList.Count == 0 && hasArmContext) { - AnsiConsole.MarkupLine(Theme.FormatWarning(MessageService.GetString("command-ls-empty_databases_hint"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatWarning(MessageService.GetString("command-ls-empty_databases_hint"))); } }; return result; @@ -183,10 +183,10 @@ private async Task ListDatabaseContainersAsync(ConnectedState stat { foreach (var name in list) { - AnsiConsole.MarkupLine(Theme.ContainerNamePromt(name)); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.ContainerNamePromt(name)); } - AnsiConsole.MarkupLine(MessageService.GetString("command-ls-found_containers", new Dictionary + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-ls-found_containers", new Dictionary { { "count", list.Count }, { "display", Theme.FormatTableValue(list.Count.ToString()) }, @@ -195,7 +195,7 @@ private async Task ListDatabaseContainersAsync(ConnectedState stat if (completionList.Count == 0 && hasArmContext) { - AnsiConsole.MarkupLine(Theme.FormatWarning(MessageService.GetString("command-ls-empty_containers_hint", new Dictionary + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatWarning(MessageService.GetString("command-ls-empty_containers_hint", new Dictionary { { "database", databaseName }, }))); @@ -294,8 +294,8 @@ private async Task ListContainerItemsAsync(ConnectedState state, S var limitCount = effectiveMaxItemCount ?? 0; returnState.RenderUser = () => { - AnsiConsole.MarkupLine(JsonOutputHighlighter.BuildMarkup(itemsElement)); - AnsiConsole.MarkupLine(MessageService.GetString("command-ls-found_items", new Dictionary + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, JsonOutputHighlighter.BuildMarkup(itemsElement)); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-ls-found_items", new Dictionary { { "count", itemCount }, { "display", Theme.FormatTableValue(itemCount.ToString()) }, @@ -303,7 +303,7 @@ private async Task ListContainerItemsAsync(ConnectedState state, S })); if (reachedLimit) { - AnsiConsole.MarkupLine(MessageService.GetString("command-results-limit_reached", new Dictionary { { "count", limitCount } })); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-results-limit_reached", new Dictionary { { "count", limitCount } })); } }; diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeContainerCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeContainerCommand.cs index ef6a0074..d5386247 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeContainerCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeContainerCommand.cs @@ -180,7 +180,7 @@ private async Task CreateContainerInDatabaseAsync(ConnectedState s id = containerName, created = true, })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetString("command-mkcon-CreatedContainer", new Dictionary { { "container", containerName } })); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-mkcon-CreatedContainer", new Dictionary { { "container", containerName } })); return commandState; } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeDbCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeDbCommand.cs index c972f376..e1d927df 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeDbCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeDbCommand.cs @@ -61,7 +61,7 @@ async Task IStateVisitor.VisitConn id = databaseName, created = true, })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetString("command-mkdb-database_created", new Dictionary { { "db", databaseName } })); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-mkdb-database_created", new Dictionary { { "db", databaseName } })); return commandState; } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs index d360bbe7..6bbf1f5c 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs @@ -147,6 +147,7 @@ private static async Task WriteItemAsync(Container container, stri int replacedCount = 0; int failCount = 0; double charge = 0.0; + var presentResults = !ShellInterpreter.Instance.IsMachineMode; foreach (var element in root.EnumerateArray()) { try @@ -166,50 +167,96 @@ private static async Task WriteItemAsync(Container container, stri else { failCount++; - ShellInterpreter.WriteLine( - MessageService.GetArgsString( - "command-mkitem-error-status-returned", - "status", - result.StatusCode.ToString())); + if (presentResults) + { + ShellInterpreter.Instance.Output.WriteLine( + ShellMessageKind.Error, + MessageService.GetArgsString( + "command-mkitem-error-status-returned", + "status", + result.StatusCode.ToString())); + } } } catch (CosmosException ce) { RequestChargeContext.Record(ce.RequestCharge); failCount++; - ShellInterpreter.WriteLine( - MessageService.GetArgsString( - "command-mkitem-error-creation-failed", - "status", - ce.StatusCode.ToString(), - "message", - CommandException.GetDisplayMessage(ce))); + if (presentResults) + { + ShellInterpreter.Instance.Output.WriteLine( + ShellMessageKind.Error, + MessageService.GetArgsString( + "command-mkitem-error-creation-failed", + "status", + ce.StatusCode.ToString(), + "message", + CommandException.GetDisplayMessage(ce))); + } } } - if (force) + if (presentResults) { - if ((createdCount + replacedCount) > 0 && failCount == 0) + if (force) { - ShellInterpreter.WriteLine( + if ((createdCount + replacedCount) > 0 && failCount == 0) + { + ShellInterpreter.Instance.Output.WriteLine( + ShellMessageKind.Information, + MessageService.GetArgsString( + "command-mkitem-upserted-multiple", + "created", + createdCount, + "replaced", + replacedCount, + "charge", + charge.ToString("F2"))); + } + else if ((createdCount + replacedCount) > 0) + { + ShellInterpreter.Instance.Output.WriteLine( + ShellMessageKind.Warning, + MessageService.GetArgsString( + "command-mkitem-upserted-partial", + "created", + createdCount, + "replaced", + replacedCount, + "failed", + failCount, + "charge", + charge.ToString("F2"))); + } + else + { + ShellInterpreter.Instance.Output.WriteLine( + ShellMessageKind.Error, + MessageService.GetArgsString( + "command-mkitem-upserted-all-failed", + "count", + failCount)); + } + } + else if (createdCount > 0 && failCount == 0) + { + ShellInterpreter.Instance.Output.WriteLine( + ShellMessageKind.Information, MessageService.GetArgsString( - "command-mkitem-upserted-multiple", - "created", + "command-mkitem-created-multiple", + "count", createdCount, - "replaced", - replacedCount, "charge", charge.ToString("F2"))); } - else if ((createdCount + replacedCount) > 0) + else if (createdCount > 0 && failCount > 0) { - ShellInterpreter.WriteLine( + ShellInterpreter.Instance.Output.WriteLine( + ShellMessageKind.Warning, MessageService.GetArgsString( - "command-mkitem-upserted-partial", - "created", + "command-mkitem-created-partial", + "success", createdCount, - "replaced", - replacedCount, "failed", failCount, "charge", @@ -217,43 +264,14 @@ private static async Task WriteItemAsync(Container container, stri } else { - ShellInterpreter.WriteLine( + ShellInterpreter.Instance.Output.WriteLine( + ShellMessageKind.Error, MessageService.GetArgsString( - "command-mkitem-upserted-all-failed", + "command-mkitem-created-all-failed", "count", failCount)); } } - else if (createdCount > 0 && failCount == 0) - { - ShellInterpreter.WriteLine( - MessageService.GetArgsString( - "command-mkitem-created-multiple", - "count", - createdCount, - "charge", - charge.ToString("F2"))); - } - else if (createdCount > 0 && failCount > 0) - { - ShellInterpreter.WriteLine( - MessageService.GetArgsString( - "command-mkitem-created-partial", - "success", - createdCount, - "failed", - failCount, - "charge", - charge.ToString("F2"))); - } - else - { - ShellInterpreter.WriteLine( - MessageService.GetArgsString( - "command-mkitem-created-all-failed", - "count", - failCount)); - } // Bulk write summary is informational; signal the caller // when one or more items failed so scripted callers don't @@ -284,7 +302,7 @@ private static async Task WriteItemAsync(Container container, stri if (result.StatusCode == System.Net.HttpStatusCode.Created) { var key = force ? "command-mkitem-upserted-created" : "command-mkitem-created-success"; - ShellInterpreter.WriteLine(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString( key, "charge", result.RequestCharge.ToString("F2"))); @@ -292,7 +310,7 @@ private static async Task WriteItemAsync(Container container, stri } else if (force && result.StatusCode == System.Net.HttpStatusCode.OK) { - ShellInterpreter.WriteLine(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-mkitem-upserted-replaced", "charge", result.RequestCharge.ToString("F2"))); @@ -300,7 +318,7 @@ private static async Task WriteItemAsync(Container container, stri } else { - ShellInterpreter.WriteLine(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, MessageService.GetArgsString( "command-mkitem-error-status-returned", "status", result.StatusCode.ToString())); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/PatchCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/PatchCommand.cs index 7abc3bc4..8c26619c 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/PatchCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/PatchCommand.cs @@ -123,7 +123,7 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co throw new CommandException("patch", MessageService.GetArgsString("command-patch-error-status-returned", "status", response.StatusCode.ToString())); } - ShellInterpreter.WriteLine(MessageService.GetArgsString("command-patch-success", "charge", response.RequestCharge.ToString("F2"))); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString("command-patch-success", "charge", response.RequestCharge.ToString("F2"))); return new CommandState { Result = new ShellJson(JsonSerializer.SerializeToElement(new diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/PwdCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/PwdCommand.cs index 06fd36d5..68991eed 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/PwdCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/PwdCommand.cs @@ -39,7 +39,7 @@ public override Task ExecuteAsync(ShellInterpreter shell, CommandS container = shell.State is ContainerState current ? current.ContainerName : null, currentLocation, })); - commandState.RenderUser = () => AnsiConsole.MarkupLine(ShellLocation.GetCurrentLocationMarkup(shell.State)); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, ShellLocation.GetCurrentLocationMarkup(shell.State)); return Task.FromResult(commandState); } } \ No newline at end of file diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs index a6cdd7b4..ac681abb 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs @@ -70,7 +70,9 @@ public async override Task ExecuteAsync(ShellInterpreter shell, Co { if (this.Bucket.HasValue && !BucketCommand.CheckBucket(this.Bucket.Value)) { - return new CommandState(); + throw new CommandException( + "query", + MessageService.GetString("error-invalid_bucket_value", new Dictionary { { "bucket", this.Bucket.Value } })); } // Get connected state @@ -562,11 +564,11 @@ private static ShellJson BuildExplainJson(string? query, PlanEvaluation evaluati private static void RenderExplain(PlanEvaluation evaluation, double requestCharge, IReadOnlyList messages) { - AnsiConsole.MarkupLine(MessageService.GetString("command-query-explain-header")); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-query-explain-header")); foreach (var message in messages) { - AnsiConsole.MarkupLine(Markup.Escape(message)); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Markup.Escape(message)); } var table = new Table(); @@ -590,9 +592,9 @@ private static void RenderExplain(PlanEvaluation evaluation, double requestCharg table.AddRow( Theme.FormatHelpName(MessageService.GetString("command-query-explain-charge")), Theme.FormatTableValue(requestCharge.ToString(CultureInfo.InvariantCulture))); - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); - AnsiConsole.MarkupLine(MessageService.GetString("command-query-explain-estimate_note")); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-query-explain-estimate_note")); } private async Task ThrowIfRequestFailedAsync(ResponseMessage response, ShellInterpreter shell) @@ -767,14 +769,14 @@ internal async Task ExecuteQueryAsync(Container container, ShellIn } using var queryDocument = JsonDocument.Parse(responseContent); - ShellInterpreter.WriteLine(MessageService.GetString("command-query-fetched", new Dictionary { { "count", queryDocument.RootElement.GetProperty("_count").ToString() } })); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetString("command-query-fetched", new Dictionary { { "count", queryDocument.RootElement.GetProperty("_count").ToString() } })); // Cosmos always returns the RU cost in the response headers, whereas query // metrics (and their TotalRequestCharge) can be null when diagnostics are // unavailable. Accumulate and report from the headers so the charge is always // correct; the detailed metrics payload is built separately from the response. totalRequestCharge += pageRequestCharge; - AnsiConsole.MarkupLine(MessageService.GetString("command-query-request_charge", new Dictionary { { "charge", pageRequestCharge.ToString("F2", CultureInfo.InvariantCulture) } })); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetString("command-query-request_charge", new Dictionary { { "charge", pageRequestCharge.ToString("F2", CultureInfo.InvariantCulture) } })); if (TryReadContinuationToken(response, out var pageContinuationToken)) { @@ -879,7 +881,7 @@ internal async Task ExecuteQueryAsync(Container container, ShellIn table.AddRow(MessageService.GetString("command-query-retrieved"), Theme.FormatTableValue(Fmt("Retrieved document count")), Theme.FormatTableValue(Fmt("Retrieved document size"))); table.AddRow(MessageService.GetString("command-query-output"), Theme.FormatTableValue(Fmt("Output document count")), Theme.FormatTableValue(Fmt("Output document size"))); - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); table = new Table(); table.AddColumns(string.Empty, string.Empty, string.Empty, string.Empty); @@ -889,13 +891,13 @@ internal async Task ExecuteQueryAsync(Container container, ShellIn table.AddRow(MessageService.GetString("command-query-vm_execution"), Theme.FormatTableValue(Fmt("VMExecution execution time"))); table.AddEmptyRow(); table.AddRow(MessageService.GetString("command-query-total"), Theme.FormatTableValue(Fmt("Total time"))); - AnsiConsole.MarkupLine(MessageService.GetString("command-query-time_label")); - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-query-time_label")); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); table = new Table(); table.AddColumns(MessageService.GetString("command-query-index_hit_ratio"), MessageService.GetString("command-query-index_lookup_time")); table.AddRow(Theme.FormatTableValue(Fmt("Index hit ratio")), Theme.FormatTableValue(Fmt("Index lookup time"))); - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); if (!string.IsNullOrWhiteSpace(response.IndexMetrics)) { @@ -923,7 +925,7 @@ internal async Task ExecuteQueryAsync(Container container, ShellIn } } - AnsiConsole.Write(indexTable); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, indexTable); } } else @@ -949,10 +951,10 @@ internal async Task ExecuteQueryAsync(Container container, ShellIn if (limitReached && effectiveMaxItemCount.HasValue) { - AnsiConsole.MarkupLine(MessageService.GetString("command-results-limit_reached", new Dictionary { { "count", effectiveMaxItemCount.Value } })); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Warning, MessageService.GetString("command-results-limit_reached", new Dictionary { { "count", effectiveMaxItemCount.Value } })); if (!continuationSupported) { - AnsiConsole.MarkupLine(MessageService.GetString("command-query-no_continuation")); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Warning, MessageService.GetString("command-query-no_continuation")); } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs index 980e3bbd..9ebca183 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs @@ -98,6 +98,7 @@ private static async Task ReplaceArrayAsync(Container container, int successCount = 0; int failCount = 0; double charge = 0.0; + var presentResults = !ShellInterpreter.Instance.IsMachineMode; foreach (var element in arrayRoot.EnumerateArray()) { @@ -111,21 +112,27 @@ private static async Task ReplaceArrayAsync(Container container, { RequestChargeContext.Record(RequestChargeContext.GetCosmosExceptionCharge(ex)); failCount++; - ShellInterpreter.WriteLine(ex.Message); + if (presentResults) + { + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, ex.Message); + } } } - if (successCount > 0 && failCount == 0) - { - ShellInterpreter.WriteLine(MessageService.GetArgsString("command-replace-success-multiple", "count", successCount, "charge", charge.ToString("F2"))); - } - else if (successCount > 0) - { - ShellInterpreter.WriteLine(MessageService.GetArgsString("command-replace-success-partial", "success", successCount, "failed", failCount, "charge", charge.ToString("F2"))); - } - else + if (presentResults) { - ShellInterpreter.WriteLine(MessageService.GetArgsString("command-replace-all-failed", "count", failCount)); + if (successCount > 0 && failCount == 0) + { + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString("command-replace-success-multiple", "count", successCount, "charge", charge.ToString("F2"))); + } + else if (successCount > 0) + { + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Warning, MessageService.GetArgsString("command-replace-success-partial", "success", successCount, "failed", failCount, "charge", charge.ToString("F2"))); + } + else + { + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, MessageService.GetArgsString("command-replace-all-failed", "count", failCount)); + } } if (failCount > 0) @@ -180,7 +187,7 @@ private static async Task ReplaceOneAsync(Container container, IReadOnly if (printSuccess) { - ShellInterpreter.WriteLine(MessageService.GetArgsString("command-replace-success-single", "charge", response.RequestCharge.ToString("F2"))); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString("command-replace-success-single", "charge", response.RequestCharge.ToString("F2"))); } return response.RequestCharge; diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmCommand.cs index 5c10cd36..aa3a9610 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmCommand.cs @@ -336,7 +336,8 @@ private async Task RemoveItemsFromContainerAsync(ConnectedState state, if (!TryGetPartitionKeyElements(element, partitionKeyPropertyNames, out var pkElements)) { - AnsiConsole.MarkupLine( + ShellInterpreter.Instance.Output.MarkupLine( + ShellMessageKind.Warning, MessageService.GetString( "command-rm-warning-missing-partition-key", new Dictionary @@ -376,7 +377,7 @@ private async Task RemoveItemsFromContainerAsync(ConnectedState state, }); commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(this.CreateResult(totalCount, dryRun))); - commandState.RenderUser = () => AnsiConsole.MarkupLine(renderMessage); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, renderMessage); commandState.RequestCharge = totalCharge > 0 ? totalCharge : null; return new ExitCode(0); @@ -590,7 +591,7 @@ private async Task RemoveExactItemAsync(Container container, Partition } commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(result)); - commandState.RenderUser = () => AnsiConsole.WriteLine(renderMessage); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.RenderLine(ShellMessageKind.Result, renderMessage); commandState.RequestCharge = requestCharge > 0 ? requestCharge : null; return new ExitCode(0); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmContainerCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmContainerCommand.cs index 615e93e3..b4af10a5 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmContainerCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmContainerCommand.cs @@ -94,7 +94,7 @@ private async Task RemoveContainerAsync(ConnectedState state, string d { this.SetOutcome( JsonSerializer.SerializeToElement(new { type = "container", id = containerName, deleted = false, dryRun = true }), - () => AnsiConsole.MarkupLine(MessageService.GetString("command-rmcon-dry-run-plan", new Dictionary { { "container", Markup.Escape(containerName) } }))); + () => ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-rmcon-dry-run-plan", new Dictionary { { "container", Markup.Escape(containerName) } }))); return 0; } @@ -104,7 +104,7 @@ private async Task RemoveContainerAsync(ConnectedState state, string d CosmosCompleteCommand.ClearContainers(); this.SetOutcome( JsonSerializer.SerializeToElement(new { type = "container", id = containerName, deleted = true, dryRun = false }), - () => AnsiConsole.MarkupLine(MessageService.GetString("command-rmcon-deleted_container", new Dictionary { { "container", Markup.Escape(containerName) } }))); + () => ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-rmcon-deleted_container", new Dictionary { { "container", Markup.Escape(containerName) } }))); } else { diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmDbCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmDbCommand.cs index a6095cd0..aa915348 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmDbCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmDbCommand.cs @@ -88,7 +88,7 @@ private async Task RemoveDatabaseAsync(ConnectedState state, ShellInte { this.SetOutcome( JsonSerializer.SerializeToElement(new { type = "database", id = databaseName, deleted = false, dryRun = true }), - () => AnsiConsole.MarkupLine(MessageService.GetString("command-rmdb-dry-run-plan", new Dictionary { { "db", Markup.Escape(databaseName) } }))); + () => ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-rmdb-dry-run-plan", new Dictionary { { "db", Markup.Escape(databaseName) } }))); return 0; } @@ -99,7 +99,7 @@ private async Task RemoveDatabaseAsync(ConnectedState state, ShellInte CosmosCompleteCommand.ClearDatabases(); this.SetOutcome( JsonSerializer.SerializeToElement(new { type = "database", id = databaseName, deleted = true, dryRun = false }), - () => AnsiConsole.MarkupLine(MessageService.GetString("command-rmdb-deleted_db", new Dictionary { { "db", Markup.Escape(databaseName) } }))); + () => ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-rmdb-deleted_db", new Dictionary { { "db", Markup.Escape(databaseName) } }))); } else { diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs index af7e6fb8..eed0b217 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs @@ -248,11 +248,11 @@ internal async Task ListAsync(Container container, ShellInterprete { if (rows.Count == 0) { - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-sproc-list-empty"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetString("command-sproc-list-empty"))); } else { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-sproc-list-title"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-sproc-list-title"))); var table = new Table(); table.AddColumn(new TableColumn(Theme.FormatSectionHeader(MessageService.GetString("command-sproc-list-column-id")))); table.AddColumn(new TableColumn(Theme.FormatSectionHeader(MessageService.GetString("command-sproc-list-column-modified")))); @@ -266,7 +266,7 @@ internal async Task ListAsync(Container container, ShellInterprete Theme.FormatTableValue(row.BodyLength.ToString())); } - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); } }; return commandState; @@ -307,7 +307,7 @@ internal async Task ExistsAsync(Container container, CommandState } commandState.Result = new ShellBool(exists); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString( + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString( exists ? "command-sproc-exists-yes" : "command-sproc-exists-no", "name", name)); @@ -363,17 +363,17 @@ private async Task CreateAsync(Container container, ShellInterpret if (!string.IsNullOrWhiteSpace(edited)) { - AnsiConsole.Clear(); - ShellInterpreter.WriteLine(MessageService.GetArgsString("command-sproc-create-preview", "name", name)); - ShellInterpreter.WriteLine(); - ShellInterpreter.WriteLine(edited); - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.ClearScreen(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction, MessageService.GetArgsString("command-sproc-create-preview", "name", name)); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction, edited); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction); } if (string.IsNullOrWhiteSpace(edited) || !ShellInterpreter.Confirm("command-sproc-create-confirm")) { commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "sproc", id = name, created = false })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString("command-sproc-create-discarded", "name", name)); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString("command-sproc-create-discarded", "name", name)); return commandState; } @@ -420,7 +420,7 @@ internal async Task WriteCreateAsync(Container container, CommandS } commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "sproc", id = name, created = true })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString( + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString( replaced ? "command-sproc-replaced" : "command-sproc-created", "name", name, @@ -449,7 +449,7 @@ internal async Task ExecAsync(Container container, CommandState co parameters, cancellationToken: token); - ShellInterpreter.WriteLine(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-sproc-executed", "name", name, @@ -475,7 +475,7 @@ internal async Task DeleteAsync(Container container, CommandState var response = await container.Scripts.DeleteStoredProcedureAsync(name, cancellationToken: token); RequestChargeContext.Record(response.RequestCharge); commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "sproc", id = name, deleted = true })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString( + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString( "command-sproc-deleted", "name", name, @@ -515,7 +515,7 @@ private async Task EditAsync(Container container, ShellInterpreter if (string.Equals(newBody, existingBody, StringComparison.Ordinal)) { commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "sproc", id = name, changed = false })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString("command-sproc-edit-unchanged", "name", name)); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString("command-sproc-edit-unchanged", "name", name)); return commandState; } @@ -524,7 +524,7 @@ private async Task EditAsync(Container container, ShellInterpreter RequestChargeContext.Record(response.RequestCharge); commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "sproc", id = name, changed = true })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString( + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString( "command-sproc-replaced", "name", name, @@ -554,7 +554,7 @@ private async Task LaunchEditorAsync(string initialBody, string name, Ca { await File.WriteAllTextAsync(tempPath, initialBody, token); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, Theme.FormatMuted(MessageService.GetArgsString( "command-sproc-edit-launching", "name", name, @@ -585,7 +585,7 @@ private async Task LaunchEditorAsync(string initialBody, string name, Ca // before reading the file back. if (launched.Elapsed < QuickEditorExit) { - ShellInterpreter.WriteLine(MessageService.GetString("command-sproc-edit-wait")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetString("command-sproc-edit-wait")); Console.ReadLine(); } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThemeCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThemeCommand.cs index e9200ea6..d274afc7 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThemeCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThemeCommand.cs @@ -59,21 +59,29 @@ public override Task ExecuteAsync(ShellInterpreter shell, CommandS "edit" => Task.FromResult(this.RunEdit(commandState)), "open" => Task.FromResult(this.RunOpen(commandState)), "reload" => Task.FromResult(this.RunReload(commandState)), - _ => Task.FromResult(this.RunUnknownAction(commandState, action)), + _ => throw UnknownAction(action), }; } - private static CommandState ReportUnknownTheme(CommandState commandState, string requested) - { - var message = MessageService.GetArgsString( - "command-theme-unknown", - "name", - Markup.Escape(requested), - "themes", - string.Join(", ", ThemeRegistry.Instance.All.Keys.Select(Markup.Escape))); - AnsiConsole.MarkupLine(message); - return new ErrorCommandState(new CommandException("theme", message)); - } + private static CommandException UnknownTheme(string requested) + => new( + "theme", + MessageService.GetArgsString( + "command-theme-unknown", + "name", + requested, + "themes", + string.Join(", ", ThemeRegistry.Instance.All.Keys))); + + private static CommandException UnknownAction(string action) + => new( + "theme", + MessageService.GetArgsString( + "command-theme-unknown-action", + "action", + action, + "actions", + "current, list, show, use (alias: set), load, validate, save, edit, open, reload")); private static string ResolveActiveName() { @@ -141,7 +149,7 @@ private static void RenderThemeSample(ThemeOptions profile, string profileName) try { Theme.Apply(profile); - AnsiConsole.MarkupLine(MessageService.GetArgsString("command-theme-sample-heading", "name", Markup.Escape(profileName.ToLowerInvariant()))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetArgsString("command-theme-sample-heading", "name", Markup.Escape(profileName.ToLowerInvariant()))); var table = new Table().HideHeaders(); table.AddColumn(string.Empty); @@ -176,7 +184,7 @@ private static void RenderThemeSample(ThemeOptions profile, string profileName) Row(MessageService.GetString("command-theme-role-help-description"), Theme.FormatHelpDescription(MessageService.GetString("command-theme-sample-description"))); Row(MessageService.GetString("command-theme-role-brackets"), string.Concat(Theme.FormatBracket("{", 0), Theme.FormatBracket("[", 1), Theme.FormatBracket("(", 2), Theme.FormatBracket(")", 2), Theme.FormatBracket("]", 1), Theme.FormatBracket("}", 0))); - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); } finally { @@ -187,7 +195,7 @@ private static void RenderThemeSample(ThemeOptions profile, string profileName) private CommandState RunCurrent(CommandState commandState) { var name = ResolveActiveName(); - commandState.RenderUser = () => AnsiConsole.MarkupLine(MessageService.GetArgsString("command-theme-active", "name", Markup.Escape(name))); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetArgsString("command-theme-active", "name", Markup.Escape(name))); commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "theme", id = name, active = true })); return commandState; } @@ -221,7 +229,7 @@ private CommandState RunList(CommandState commandState) { foreach (var (marker, name, source) in rows) { - AnsiConsole.MarkupLine($" {marker} {Markup.Escape(name)} {Theme.FormatMuted(source)}"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, $" {marker} {Markup.Escape(name)} {Theme.FormatMuted(source)}"); } }; commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "theme", values = items })); @@ -233,7 +241,7 @@ private CommandState RunShow(CommandState commandState) var profileName = string.IsNullOrWhiteSpace(this.Name) ? ResolveActiveName() : this.Name; if (!ThemeProfiles.TryGet(profileName, out var profile)) { - return ReportUnknownTheme(commandState, profileName); + throw UnknownTheme(profileName); } commandState.RenderUser = () => RenderThemeSample(profile, profileName); @@ -245,18 +253,17 @@ private CommandState RunUse(CommandState commandState) { if (string.IsNullOrWhiteSpace(this.Name)) { - AnsiConsole.MarkupLine(MessageService.GetString("command-theme-use-missing-name")); - return new ErrorCommandState(new CommandException("theme", MessageService.GetString("command-theme-use-missing-name"))); + throw new CommandException("theme", MessageService.GetString("command-theme-use-missing-name")); } if (!ThemeProfiles.TryGet(this.Name, out var profile)) { - return ReportUnknownTheme(commandState, this.Name); + throw UnknownTheme(this.Name); } Theme.Apply(profile); var name = this.Name.ToLowerInvariant(); - commandState.RenderUser = () => AnsiConsole.MarkupLine(MessageService.GetArgsString("command-theme-applied", "name", Markup.Escape(name))); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetArgsString("command-theme-applied", "name", Markup.Escape(name))); commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "theme", id = name, applied = true })); return commandState; } @@ -267,8 +274,7 @@ private CommandState RunLoad(CommandState commandState) if (string.IsNullOrWhiteSpace(requested)) { var message = MessageService.GetString("command-theme-load-missing-path"); - AnsiConsole.MarkupLine(message); - return new ErrorCommandState(new CommandException("theme", message)); + throw new CommandException("theme", message); } var path = ResolveThemePath(requested); @@ -277,7 +283,7 @@ private CommandState RunLoad(CommandState commandState) { var result = ThemeRegistry.Instance.LoadFile(path); Theme.Apply(result.Options); - AnsiConsole.MarkupLine(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-theme-loaded", "name", Markup.Escape(result.Name), @@ -285,7 +291,7 @@ private CommandState RunLoad(CommandState commandState) Markup.Escape(result.Source))); foreach (var warning in result.Warnings) { - AnsiConsole.MarkupLine(Theme.FormatWarning(warning)); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Warning, Theme.FormatWarning(warning)); } commandState.RenderUser = () => { }; @@ -301,14 +307,12 @@ private CommandState RunLoad(CommandState commandState) } catch (ThemeLoadException ex) { - AnsiConsole.MarkupLine(Theme.FormatError(ex.Message)); - return new ErrorCommandState(new CommandException("theme", ex.Message)); + throw new CommandException("theme", ex.Message); } catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) { var message = MessageService.GetArgsString("command-theme-load-not-found", "path", path); - AnsiConsole.MarkupLine(Theme.FormatError(message)); - return new ErrorCommandState(new CommandException("theme", message)); + throw new CommandException("theme", message); } } @@ -317,8 +321,7 @@ private CommandState RunSave(CommandState commandState) if (string.IsNullOrWhiteSpace(this.Name)) { var message = MessageService.GetString("command-theme-save-missing-name"); - AnsiConsole.MarkupLine(message); - return new ErrorCommandState(new CommandException("theme", message)); + throw new CommandException("theme", message); } string path; @@ -333,8 +336,7 @@ private CommandState RunSave(CommandState commandState) || this.Name != System.IO.Path.GetFileName(this.Name)) { var message = MessageService.GetArgsString("command-theme-save-invalid-name", "name", this.Name); - AnsiConsole.MarkupLine(Theme.FormatError(message)); - return new ErrorCommandState(new CommandException("theme", message)); + throw new CommandException("theme", message); } path = System.IO.Path.Combine(ThemeFile.DefaultUserThemesDirectory(), this.Name + ".toml"); @@ -347,20 +349,19 @@ private CommandState RunSave(CommandState commandState) if (File.Exists(path) && !this.Force) { var message = MessageService.GetArgsString("command-theme-save-exists", "path", path); - AnsiConsole.MarkupLine(Theme.FormatWarning(message)); - return new ErrorCommandState(new CommandException("theme", message)); + throw new CommandException("theme", message); } try { ThemeFile.Save(this.Name, Theme.Current, path); - AnsiConsole.MarkupLine(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-theme-saved", "name", Markup.Escape(this.Name), "path", Markup.Escape(System.IO.Path.GetFullPath(path)))); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetArgsString("command-theme-save-hint-reload", "name", this.Name))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, Theme.FormatMuted(MessageService.GetArgsString("command-theme-save-hint-reload", "name", this.Name))); commandState.RenderUser = () => { }; commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { @@ -374,8 +375,7 @@ private CommandState RunSave(CommandState commandState) catch (Exception ex) { var message = MessageService.GetArgsString("command-theme-save-failed", "path", path, "message", ex.Message); - AnsiConsole.MarkupLine(Theme.FormatError(message)); - return new ErrorCommandState(new CommandException("theme", message, ex)); + throw new CommandException("theme", message, ex); } } @@ -406,18 +406,22 @@ private CommandState RunValidateFile(CommandState commandState, string path) try { var result = ThemeRegistry.Instance.ValidateFile(path); - AnsiConsole.MarkupLine(MessageService.GetArgsString( - "command-theme-validated", - "name", - Markup.Escape(result.Name), - "path", - Markup.Escape(result.Source))); - foreach (var warning in result.Warnings) + var strictFailure = this.Strict && result.Warnings.Count > 0; + if (!ShellInterpreter.Instance.IsMachineMode || !strictFailure) { - AnsiConsole.MarkupLine(Theme.FormatWarning(warning)); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( + "command-theme-validated", + "name", + Markup.Escape(result.Name), + "path", + Markup.Escape(result.Source))); + foreach (var warning in result.Warnings) + { + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Warning, Theme.FormatWarning(warning)); + } } - if (this.Strict && result.Warnings.Count > 0) + if (strictFailure) { var strictMessage = MessageService.GetArgsString( "command-theme-validate-strict-failed", @@ -425,8 +429,7 @@ private CommandState RunValidateFile(CommandState commandState, string path) result.Name, "count", result.Warnings.Count); - AnsiConsole.MarkupLine(Theme.FormatError(strictMessage)); - return new ErrorCommandState(new CommandException("theme", strictMessage)); + throw new CommandException("theme", strictMessage); } commandState.RenderUser = () => { }; @@ -443,24 +446,27 @@ private CommandState RunValidateFile(CommandState commandState, string path) } catch (ThemeLoadException ex) { - AnsiConsole.MarkupLine(Theme.FormatError(ex.Message)); - return new ErrorCommandState(new CommandException("theme", ex.Message)); + throw new CommandException("theme", ex.Message); } catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) { var message = MessageService.GetArgsString("command-theme-load-not-found", "path", path); - AnsiConsole.MarkupLine(Theme.FormatError(message)); - return new ErrorCommandState(new CommandException("theme", message)); + throw new CommandException("theme", message); } } private CommandState RunValidateDirectory(CommandState commandState, string directory) { var files = ThemeFile.EnumerateThemeFiles(directory); + var presentResults = !ShellInterpreter.Instance.IsMachineMode; if (files.Length == 0) { var emptyMessage = MessageService.GetArgsString("command-theme-validate-no-files", "directory", directory); - AnsiConsole.MarkupLine(Theme.FormatMuted(emptyMessage)); + if (presentResults) + { + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, Theme.FormatMuted(emptyMessage)); + } + commandState.RenderUser = () => { }; commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { @@ -474,6 +480,7 @@ private CommandState RunValidateDirectory(CommandState commandState, string dire } var fileResults = new List>(); + var pendingWarnings = new List(); var validCount = 0; var invalidCount = 0; @@ -496,17 +503,30 @@ private CommandState RunValidateDirectory(CommandState commandState, string dire if (failedStrict) { invalidCount++; - AnsiConsole.MarkupLine($" {Theme.FormatError("\u2717")} {Markup.Escape(result.Name)} {Theme.FormatMuted("(" + System.IO.Path.GetFileName(file) + ")")}"); + if (presentResults) + { + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Error, $" {Theme.FormatError("\u2717")} {Markup.Escape(result.Name)} {Theme.FormatMuted("(" + System.IO.Path.GetFileName(file) + ")")}"); + } } else { validCount++; - AnsiConsole.MarkupLine($" {Theme.FormatHelpAccent("\u2713")} {Markup.Escape(result.Name)} {Theme.FormatMuted("(" + System.IO.Path.GetFileName(file) + ")")}"); + if (presentResults) + { + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, $" {Theme.FormatHelpAccent("\u2713")} {Markup.Escape(result.Name)} {Theme.FormatMuted("(" + System.IO.Path.GetFileName(file) + ")")}"); + } } - foreach (var warning in result.Warnings) + if (presentResults) + { + foreach (var warning in result.Warnings) + { + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Warning, " " + Theme.FormatWarning(warning)); + } + } + else { - AnsiConsole.MarkupLine(" " + Theme.FormatWarning(warning)); + pendingWarnings.AddRange(result.Warnings); } } catch (Exception ex) when (ex is ThemeLoadException || ex is FileNotFoundException || ex is DirectoryNotFoundException) @@ -514,22 +534,44 @@ private CommandState RunValidateDirectory(CommandState commandState, string dire invalidCount++; entry["valid"] = false; entry["error"] = ex.Message; - AnsiConsole.MarkupLine($" {Theme.FormatError("\u2717")} {Markup.Escape(System.IO.Path.GetFileName(file))}"); - AnsiConsole.MarkupLine(" " + Theme.FormatError(ex.Message)); + if (presentResults) + { + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Error, $" {Theme.FormatError("\u2717")} {Markup.Escape(System.IO.Path.GetFileName(file))}"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Error, " " + Theme.FormatError(ex.Message)); + } } fileResults.Add(entry); } - var summary = MessageService.GetArgsString( - "command-theme-validate-summary", - "valid", - validCount, - "total", - files.Length, - "directory", - Markup.Escape(directory)); - AnsiConsole.MarkupLine(summary); + if (invalidCount > 0) + { + throw new CommandException("theme", MessageService.GetArgsString( + "command-theme-validate-summary", + "valid", + validCount, + "total", + files.Length, + "directory", + directory)); + } + + foreach (var warning in pendingWarnings) + { + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Warning, " " + Theme.FormatWarning(warning)); + } + + if (presentResults) + { + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( + "command-theme-validate-summary", + "valid", + validCount, + "total", + files.Length, + "directory", + Markup.Escape(directory))); + } commandState.RenderUser = () => { }; commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new @@ -541,11 +583,6 @@ private CommandState RunValidateDirectory(CommandState commandState, string dire invalid = invalidCount, })); - if (invalidCount > 0) - { - return new ErrorCommandState(new CommandException("theme", summary)); - } - return commandState; } @@ -572,11 +609,10 @@ private CommandState RunOpen(CommandState commandState) targetPath, "message", ex.Message); - AnsiConsole.MarkupLine(Theme.FormatError(message)); - return new ErrorCommandState(new CommandException("theme", message, ex)); + throw new CommandException("theme", message, ex); } - AnsiConsole.MarkupLine(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-theme-opened", "path", Markup.Escape(targetPath))); @@ -597,8 +633,7 @@ private CommandState RunEdit(CommandState commandState) if (string.IsNullOrWhiteSpace(requested)) { var message = MessageService.GetString("command-theme-edit-missing-name"); - AnsiConsole.MarkupLine(message); - return new ErrorCommandState(new CommandException("theme", message)); + throw new CommandException("theme", message); } // If the argument is itself a path to an existing file, edit it in place. @@ -633,11 +668,10 @@ private CommandState RunEdit(CommandState commandState) var message = MessageService.GetArgsString( "command-theme-edit-builtin-needs-force", "name", - Markup.Escape(registration.Name), + registration.Name, "path", - Markup.Escape(seedPath)); - AnsiConsole.MarkupLine(Theme.FormatWarning(message)); - return new ErrorCommandState(new CommandException("theme", message)); + seedPath); + throw new CommandException("theme", message); } try @@ -652,12 +686,11 @@ private CommandState RunEdit(CommandState commandState) seedPath, "message", ex.Message); - AnsiConsole.MarkupLine(Theme.FormatError(failed)); - return new ErrorCommandState(new CommandException("theme", failed, ex)); + throw new CommandException("theme", failed, ex); } targetPath = seedPath; - AnsiConsole.MarkupLine(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-theme-edit-seeded", "name", Markup.Escape(registration.Name), @@ -668,18 +701,17 @@ private CommandState RunEdit(CommandState commandState) } else { - return ReportUnknownTheme(commandState, requested); + throw UnknownTheme(requested); } var editor = ExternalEditor.Resolve(null); if (editor is null) { var message = MessageService.GetString("command-theme-edit-no-editor"); - AnsiConsole.MarkupLine(Theme.FormatError(message)); - return new ErrorCommandState(new CommandException("theme", message)); + throw new CommandException("theme", message); } - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, Theme.FormatMuted(MessageService.GetArgsString( "command-theme-edit-launching", "path", targetPath!, @@ -708,8 +740,7 @@ private CommandState RunEdit(CommandState commandState) targetPath!, "message", ex.Message); - AnsiConsole.MarkupLine(Theme.FormatError(message)); - return new ErrorCommandState(new CommandException("theme", message, ex)); + throw new CommandException("theme", message, ex); } if (exitCode != 0) @@ -720,8 +751,7 @@ private CommandState RunEdit(CommandState commandState) editor.DisplayName, "code", exitCode); - AnsiConsole.MarkupLine(Theme.FormatWarning(message)); - return new ErrorCommandState(new CommandException("theme", message)); + throw new CommandException("theme", message); } // Reload registry so renamed/edited files are picked up, then apply the @@ -735,7 +765,7 @@ private CommandState RunEdit(CommandState commandState) // Re-scan the directory too so other files stay in sync with disk. registry.LoadFromDirectory(ThemeFile.DefaultUserThemesDirectory()); - AnsiConsole.MarkupLine(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-theme-edit-applied", "name", Markup.Escape(result.Name), @@ -744,7 +774,7 @@ private CommandState RunEdit(CommandState commandState) foreach (var warning in result.Warnings) { - AnsiConsole.MarkupLine(Theme.FormatWarning(warning)); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Warning, Theme.FormatWarning(warning)); } commandState.RenderUser = () => { }; @@ -766,8 +796,7 @@ private CommandState RunEdit(CommandState commandState) targetPath!, "message", ex.Message); - AnsiConsole.MarkupLine(Theme.FormatError(message)); - return new ErrorCommandState(new CommandException("theme", message, ex)); + throw new CommandException("theme", message, ex); } } @@ -778,7 +807,7 @@ private CommandState RunReload(CommandState commandState) var directory = ThemeFile.DefaultUserThemesDirectory(); var loaded = registry.LoadFromDirectory(directory); - AnsiConsole.MarkupLine(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-theme-reloaded", "count", loaded, @@ -786,7 +815,7 @@ private CommandState RunReload(CommandState commandState) Markup.Escape(directory))); foreach (var warning in registry.Warnings) { - AnsiConsole.MarkupLine(Theme.FormatWarning(warning)); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Warning, Theme.FormatWarning(warning)); } commandState.RenderUser = () => { }; @@ -800,16 +829,4 @@ private CommandState RunReload(CommandState commandState) })); return commandState; } - - private CommandState RunUnknownAction(CommandState commandState, string action) - { - var message = MessageService.GetArgsString( - "command-theme-unknown-action", - "action", - Markup.Escape(action), - "actions", - "current, list, show, use (alias: set), load, validate, save, edit, open, reload"); - AnsiConsole.MarkupLine(message); - return new ErrorCommandState(new CommandException("theme", message)); - } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThroughputCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThroughputCommand.cs index 2289c6e3..feacc280 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThroughputCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThroughputCommand.cs @@ -154,7 +154,7 @@ void Row(string labelKey, string value) => Row("command-throughput-label-min", view.MinThroughput.Value.ToString(CultureInfo.InvariantCulture)); } - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); }, }; } @@ -215,8 +215,8 @@ void Row(string labelKey, string value) => Row("command-throughput-dry-run-label-current", DescribeMode(currentMode, current.Throughput, current.AutoscaleMaxThroughput)); Row("command-throughput-dry-run-label-planned", DescribeMode(plannedMode, plannedAutoscale ? null : plannedRu, plannedAutoscale ? plannedRu : null)); - AnsiConsole.Write(table); - ShellInterpreter.WriteLine(MessageService.GetString("command-throughput-dry-run-note")); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-throughput-dry-run-note")); }, }; } @@ -259,7 +259,7 @@ private static bool ConfirmWrite(ShellInterpreter shell, bool yes, string databa string resourceName = containerName ?? databaseName; string modeLabel = MessageService.GetString(isAutoscale ? "command-throughput-mode-autoscale" : "command-throughput-mode-manual"); string ruText = ru.ToString(CultureInfo.InvariantCulture); - AnsiConsole.MarkupLine(MessageService.GetArgsString("command-throughput-confirm_summary", "resource", Markup.Escape(resourceName), "mode", modeLabel, "ru", ruText)); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.RequiredInstruction, MessageService.GetArgsString("command-throughput-confirm_summary", "resource", Markup.Escape(resourceName), "mode", modeLabel, "ru", ruText)); return ShellInterpreter.Confirm("command-throughput-confirm"); } @@ -318,7 +318,7 @@ private async Task ExecuteOnScopeAsync(ConnectedState state, Shell return new CommandState { Result = new ShellJson(cancelledDoc.RootElement.Clone()), - RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetString("command-throughput-cancelled")), + RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-throughput-cancelled")), }; } @@ -354,7 +354,7 @@ private async Task ExecuteOnScopeAsync(ConnectedState state, Shell var renderTable = built.RenderUser; built.RenderUser = () => { - ShellInterpreter.WriteLine(MessageService.GetString("command-throughput-updated")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-throughput-updated")); renderTable?.Invoke(); }; return built; diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/TriggerCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/TriggerCommand.cs index e13c0863..f62cd31f 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/TriggerCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/TriggerCommand.cs @@ -221,11 +221,11 @@ internal async Task ListAsync(Container container, ShellInterprete { if (rows.Count == 0) { - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-trigger-list-empty"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetString("command-trigger-list-empty"))); } else { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-trigger-list-title"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-trigger-list-title"))); var table = new Table(); table.AddColumn(new TableColumn(Theme.FormatSectionHeader(MessageService.GetString("command-trigger-list-column-id")))); table.AddColumn(new TableColumn(Theme.FormatSectionHeader(MessageService.GetString("command-trigger-list-column-type")))); @@ -241,7 +241,7 @@ internal async Task ListAsync(Container container, ShellInterprete Theme.FormatTableValue(row.BodyLength.ToString())); } - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); } }; return commandState; @@ -282,7 +282,7 @@ internal async Task ExistsAsync(Container container, CommandState } commandState.Result = new ShellBool(exists); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString( + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString( exists ? "command-trigger-exists-yes" : "command-trigger-exists-no", "name", name)); @@ -346,17 +346,17 @@ private async Task CreateAsync(Container container, ShellInterpret if (!string.IsNullOrWhiteSpace(edited)) { - AnsiConsole.Clear(); - ShellInterpreter.WriteLine(MessageService.GetArgsString("command-trigger-create-preview", "name", name)); - ShellInterpreter.WriteLine(); - ShellInterpreter.WriteLine(edited); - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.ClearScreen(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction, MessageService.GetArgsString("command-trigger-create-preview", "name", name)); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction, edited); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction); } if (string.IsNullOrWhiteSpace(edited) || !ShellInterpreter.Confirm("command-trigger-create-confirm")) { commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "trigger", id = name, created = false })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString("command-trigger-create-discarded", "name", name)); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString("command-trigger-create-discarded", "name", name)); return commandState; } @@ -416,7 +416,7 @@ internal async Task WriteCreateAsync(Container container, CommandS triggerType = triggerType.ToString(), triggerOperation = triggerOperation.ToString(), })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString( + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString( replaced ? "command-trigger-replaced" : "command-trigger-created", "name", name, @@ -434,7 +434,7 @@ internal async Task DeleteAsync(Container container, CommandState var response = await container.Scripts.DeleteTriggerAsync(name, cancellationToken: token); RequestChargeContext.Record(response.RequestCharge); commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "trigger", id = name, deleted = true })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString( + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString( "command-trigger-deleted", "name", name, @@ -475,7 +475,7 @@ private async Task EditAsync(Container container, ShellInterpreter if (string.Equals(newBody, existingBody, StringComparison.Ordinal)) { commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "trigger", id = name, changed = false })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString("command-trigger-edit-unchanged", "name", name)); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString("command-trigger-edit-unchanged", "name", name)); return commandState; } @@ -490,7 +490,7 @@ private async Task EditAsync(Container container, ShellInterpreter RequestChargeContext.Record(response.RequestCharge); commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "trigger", id = name, changed = true })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString( + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString( "command-trigger-replaced", "name", name, @@ -520,7 +520,7 @@ private async Task LaunchEditorAsync(string initialBody, string name, Ca { await File.WriteAllTextAsync(tempPath, initialBody, token); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, Theme.FormatMuted(MessageService.GetArgsString( "command-trigger-edit-launching", "name", name, @@ -551,7 +551,7 @@ private async Task LaunchEditorAsync(string initialBody, string name, Ca // before reading the file back. if (launched.Elapsed < QuickEditorExit) { - ShellInterpreter.WriteLine(MessageService.GetString("command-trigger-edit-wait")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetString("command-trigger-edit-wait")); Console.ReadLine(); } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/TtlCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/TtlCommand.cs index abf01d9b..fd2988b9 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/TtlCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/TtlCommand.cs @@ -201,7 +201,7 @@ private async Task ExecuteOnContainerAsync(ConnectedState state, s } var analyticalView = await CosmosResourceFacade.ReplaceAnalyticalTimeToLiveAsync(state, databaseName, containerName, targetTtl, token); - ShellInterpreter.WriteLine(MessageService.GetString("command-ttl-analytical-updated")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetString("command-ttl-analytical-updated")); return BuildAnalyticalResult(containerName, analyticalView); } @@ -212,7 +212,7 @@ private async Task ExecuteOnContainerAsync(ConnectedState state, s } var view = await CosmosResourceFacade.ReplaceTimeToLiveAsync(state, databaseName, containerName, targetTtl, token); - ShellInterpreter.WriteLine(MessageService.GetString("command-ttl-updated")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetString("command-ttl-updated")); return BuildResult(containerName, view); } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/UdfCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/UdfCommand.cs index d5d2384d..94d4773e 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/UdfCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/UdfCommand.cs @@ -172,11 +172,11 @@ internal async Task ListAsync(Container container, ShellInterprete { if (rows.Count == 0) { - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-udf-list-empty"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetString("command-udf-list-empty"))); } else { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-udf-list-title"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-udf-list-title"))); var table = new Table(); table.AddColumn(new TableColumn(Theme.FormatSectionHeader(MessageService.GetString("command-udf-list-column-id")))); table.AddColumn(new TableColumn(Theme.FormatSectionHeader(MessageService.GetString("command-udf-list-column-size"))).RightAligned()); @@ -188,7 +188,7 @@ internal async Task ListAsync(Container container, ShellInterprete Theme.FormatTableValue(row.BodyLength.ToString())); } - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); } }; return commandState; @@ -229,7 +229,7 @@ internal async Task ExistsAsync(Container container, CommandState } commandState.Result = new ShellBool(exists); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString( + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString( exists ? "command-udf-exists-yes" : "command-udf-exists-no", "name", name)); @@ -285,17 +285,17 @@ private async Task CreateAsync(Container container, ShellInterpret if (!string.IsNullOrWhiteSpace(edited)) { - AnsiConsole.Clear(); - ShellInterpreter.WriteLine(MessageService.GetArgsString("command-udf-create-preview", "name", name)); - ShellInterpreter.WriteLine(); - ShellInterpreter.WriteLine(edited); - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.ClearScreen(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction, MessageService.GetArgsString("command-udf-create-preview", "name", name)); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction, edited); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction); } if (string.IsNullOrWhiteSpace(edited) || !ShellInterpreter.Confirm("command-udf-create-confirm")) { commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "udf", id = name, created = false })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString("command-udf-create-discarded", "name", name)); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString("command-udf-create-discarded", "name", name)); return commandState; } @@ -342,7 +342,7 @@ internal async Task WriteCreateAsync(Container container, CommandS } commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "udf", id = name, created = true })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString( + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString( replaced ? "command-udf-replaced" : "command-udf-created", "name", name, @@ -360,7 +360,7 @@ internal async Task DeleteAsync(Container container, CommandState var response = await container.Scripts.DeleteUserDefinedFunctionAsync(name, cancellationToken: token); RequestChargeContext.Record(response.RequestCharge); commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "udf", id = name, deleted = true })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString( + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString( "command-udf-deleted", "name", name, @@ -400,7 +400,7 @@ private async Task EditAsync(Container container, ShellInterpreter if (string.Equals(newBody, existingBody, StringComparison.Ordinal)) { commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "udf", id = name, changed = false })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString("command-udf-edit-unchanged", "name", name)); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString("command-udf-edit-unchanged", "name", name)); return commandState; } @@ -409,7 +409,7 @@ private async Task EditAsync(Container container, ShellInterpreter RequestChargeContext.Record(response.RequestCharge); commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "udf", id = name, changed = true })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString( + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString( "command-udf-replaced", "name", name, @@ -439,7 +439,7 @@ private async Task LaunchEditorAsync(string initialBody, string name, Ca { await File.WriteAllTextAsync(tempPath, initialBody, token); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, Theme.FormatMuted(MessageService.GetArgsString( "command-udf-edit-launching", "name", name, @@ -470,7 +470,7 @@ private async Task LaunchEditorAsync(string initialBody, string name, Ca // before reading the file back. if (launched.Elapsed < QuickEditorExit) { - ShellInterpreter.WriteLine(MessageService.GetString("command-udf-edit-wait")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetString("command-udf-edit-wait")); Console.ReadLine(); } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/WatchCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/WatchCommand.cs index bf11ff49..e203bb7a 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/WatchCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/WatchCommand.cs @@ -50,6 +50,11 @@ public async override Task ExecuteAsync(ShellInterpreter shell, Co { ArgumentNullException.ThrowIfNull(shell); + if (shell.IsMachineMode && this.Max is not > 0) + { + throw new CommandException("watch", MessageService.GetString("command-watch-error-machine_max_required")); + } + if (shell.State is not ConnectedState connectedState) { throw new NotConnectedException("watch"); @@ -166,7 +171,7 @@ private async Task WatchAsync(ShellInterpreter shell, Container co var collected = (max.HasValue || redirected) ? new List() : null; var count = 0; - AnsiConsole.MarkupLine(MessageService.GetArgsString("command-watch-started", "container", Theme.ContainerNamePromt(container.Id))); + shell.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString("command-watch-started", "container", Theme.ContainerNamePromt(container.Id))); try { @@ -195,9 +200,9 @@ private async Task WatchAsync(ShellInterpreter shell, Container co var limitReached = false; foreach (var element in documents) { - if (!redirected) + if (!redirected && (collected == null || !shell.IsMachineMode)) { - AnsiConsole.MarkupLine(JsonOutputHighlighter.BuildMarkup(element)); + shell.Output.MarkupLine(ShellMessageKind.Result, JsonOutputHighlighter.BuildMarkup(element)); } collected?.Add(element); @@ -221,7 +226,7 @@ private async Task WatchAsync(ShellInterpreter shell, Container co // Ctrl+C: stop tailing and report what was seen so far. } - AnsiConsole.MarkupLine(MessageService.GetArgsString("command-watch-stopped", "count", count.ToString())); + shell.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString("command-watch-stopped", "count", count.ToString())); var result = new CommandState(); result.SetFormat(this.OutputFormat); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/WhoamiCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/WhoamiCommand.cs index 1a50b55f..602d7d47 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/WhoamiCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/WhoamiCommand.cs @@ -122,7 +122,7 @@ public async override Task ExecuteAsync(ShellInterpreter shell, Co private static void RenderTable(string credentialType, Dictionary? identity, string? note) { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-whoami-title"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-whoami-title"))); var table = new Table(); table.AddColumns(string.Empty, string.Empty); @@ -141,11 +141,11 @@ private static void RenderTable(string credentialType, Dictionary + /// Removes every command matching , including all of its aliases. + /// + internal void RemoveCommands(Func predicate) + { + foreach (var name in this.Commands.Where(entry => predicate(entry.Value)).Select(entry => entry.Key).ToList()) + { + this.Commands.Remove(name); + } + } + internal bool IsOptionPrefix(string? currentCommand, string name) { if (currentCommand == null) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosCompletionRenderer.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosCompletionRenderer.cs index e505ccfb..d2259c79 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosCompletionRenderer.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosCompletionRenderer.cs @@ -12,6 +12,7 @@ internal class CosmosCompletionRenderer(ShellInterpreter shellInterpreter) : ILi { private readonly ShellInterpreter shellInterpreter = shellInterpreter; + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CZ0003:Direct console output", Justification = "Inline completion hints are drawn only inside the interactive RadLine editor.")] void ILineDecorationRenderer.RenderLineDecoration(LineBuffer buffer) { var word = buffer.Content; diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/OutputPolicy.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/OutputPolicy.cs index 758e960b..98bd451e 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/OutputPolicy.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/OutputPolicy.cs @@ -13,6 +13,21 @@ namespace Azure.Data.Cosmos.Shell.Core; /// internal static class OutputPolicy { + // Command code presents results only for interactive users. Machine-mode results are + // emitted once by the interpreter through ShellOutput.WriteResult; MCP stdio returns + // them in protocol responses. + internal static bool ShouldPresent(ShellMessageKind kind, bool quiet, bool stdio, bool machineMode) + => kind switch + { + ShellMessageKind.Information or ShellMessageKind.Progress => !quiet, + ShellMessageKind.Result => !stdio && !machineMode, + ShellMessageKind.Warning or ShellMessageKind.Error or ShellMessageKind.RequiredInstruction => true, + _ => throw new ArgumentOutOfRangeException(nameof(kind)), + }; + + internal static bool UseStandardError(ShellMessageKind kind, bool machineMode, bool stdio) + => stdio || (machineMode && kind is ShellMessageKind.Warning or ShellMessageKind.Error or ShellMessageKind.RequiredInstruction); + /// /// Determines whether the given format is a structured, machine-consumable format. /// and are structured diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs index 638f8140..16209ee1 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs @@ -33,11 +33,6 @@ public partial class ShellInterpreter : IDisposable private const string SessionRequestChargeWarningThresholdVariable = "sessionRequestChargeWarningThreshold"; - // Declared before Instance: static initializers run in order and the constructor reads history. - private static readonly object HistoryFileLock = new(); - - internal static readonly ShellInterpreter Instance = new(); - private const int MAXHISTORYITEMS = 60; private const int HistoryFileOpenRetryCount = 10; @@ -53,12 +48,18 @@ public partial class ShellInterpreter : IDisposable // user command that just happens to start with the prefix string. private const string EncodedHistoryLineMarker = "E:"; + private static readonly object HistoryFileLock = new(); + + private static readonly Lazy SharedInstance = new(() => new ShellInterpreter(historyEnabled: !stdioStartup)); + private static readonly TimeSpan HistoryFileOpenRetryDelay = TimeSpan.FromMilliseconds(25); private static readonly TimeSpan LocalEmulatorOperationTimeout = TimeSpan.FromSeconds(10); private static CancellationTokenSource? currentTokenSource; + private static bool stdioStartup; + private readonly string cfgPath; private readonly string welcomeMarkerFile; @@ -107,9 +108,13 @@ public partial class ShellInterpreter : IDisposable private long sessionRequestChargeGeneration; - internal ShellInterpreter(string? configPath = null) + internal ShellInterpreter(string? configPath = null, bool historyEnabled = true) { this.State = new DisconnectedState(); + this.Output = new ShellOutput( + () => this.Options?.Quiet == true, + () => this.IsMcpStdio, + () => stdioStartup || this.IsMachineMode); // editor.KeyBindings.Add(ConsoleKey.Escape); // TODO: Support selection commands? @@ -126,27 +131,30 @@ internal ShellInterpreter(string? configPath = null) this.HistoryFile = Path.Join(this.cfgPath, "cmd_history"); this.welcomeMarkerFile = Path.Join(this.cfgPath, "welcome_seen"); - try + if (historyEnabled) { - List entries = []; - lock (HistoryFileLock) + try { - if (File.Exists(this.HistoryFile)) + List entries = []; + lock (HistoryFileLock) { - using var stream = this.OpenHistoryFileWithExclusiveLock(FileAccess.Read); - entries = ReadHistoryEntries(stream); + if (File.Exists(this.HistoryFile)) + { + using var stream = this.OpenHistoryFileWithExclusiveLock(FileAccess.Read); + entries = ReadHistoryEntries(stream); + } } - } - foreach (var entry in entries) + foreach (var entry in entries) + { + this.RecordHistoryEntry(entry, persist: false); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - this.RecordHistoryEntry(entry, persist: false); + System.Diagnostics.Debug.WriteLine(ex); } } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - System.Diagnostics.Debug.WriteLine(ex); - } Console.CancelKeyPress += this.Console_CancelKeyPress; this.editorCancelTokenSource = new CancellationTokenSource(); @@ -154,6 +162,16 @@ internal ShellInterpreter(string? configPath = null) internal event Action? LocationChanged; + internal static ShellInterpreter Instance => SharedInstance.Value; + + internal ShellOutput Output { get; } + + /// + /// Gets a value indicating whether the shell runs as a headless MCP stdio server, + /// including during startup before options are applied. + /// + internal bool IsMcpStdio => stdioStartup || this.Options?.McpStdio == true; + /// /// Gets the line editor instance used by the shell, or null if not available. /// @@ -367,14 +385,16 @@ internal State State /// interactive rendering (friendly views, ANSI colors, banners) is suppressed in /// favor of deterministic structured output. Machine mode is entered via /// --quiet, a structured output format (--output json or --output csv), - /// or an execute-and-quit (-c) invocation. The human-facing table and + /// a headless --mcp-stdio server, or an execute-and-quit (-c) invocation. The human-facing table and /// user formats are not machine mode. See . /// - internal bool IsMachineMode => OutputPolicy.IsMachineMode( + internal bool IsMachineMode => this.Options?.McpStdio == true || OutputPolicy.IsMachineMode( this.Options?.Output, this.Options?.Quiet == true, !string.IsNullOrWhiteSpace(this.Options?.ExecuteAndQuit)); + internal bool McpConfirmationApproved { get; set; } + /// /// Gets the session default , resolved from the global /// --output option and then the COSMOSDB_SHELL_FORMAT environment variable. @@ -406,83 +426,71 @@ internal OutputFormat DefaultOutputFormat } /// - /// Create a new instance of the class. + /// Disables history loading before the shared stdio interpreter is constructed. + /// + internal static void ConfigureStdioStartup() + { + if (SharedInstance.IsValueCreated) + { + throw new InvalidOperationException("Stdio mode must be configured before creating the shell interpreter."); + } + + stdioStartup = true; + } + + /// + /// Creates a new shell interpreter instance. /// - /// A new instance of the class. + /// A new shell interpreter. public static ShellInterpreter CreateInstance() { return new ShellInterpreter(); } /// - /// Writes the specified message to the standard output stream, using the specified format parameters. + /// Writes an informational message through the shell output policy, using the specified format parameters. /// /// The message to write. /// An array of objects to format. public static void WriteLine(string message, params object[] par) { - if (Instance?.Options?.Quiet == true) - { - return; - } - - Console.WriteLine(message, par); + Instance.Output.WriteLine(ShellMessageKind.Information, message, par); } /// - /// Writes the specified message to the standard output stream. + /// Writes an informational message through the shell output policy. /// /// The message to write. public static void WriteLine(string message) { - if (Instance?.Options?.Quiet == true) - { - return; - } - - Console.WriteLine(message); + Instance.Output.WriteLine(ShellMessageKind.Information, message); } /// - /// Writes an empty line to the standard output stream. + /// Writes an informational empty line through the shell output policy. /// public static void WriteLine() { - if (Instance?.Options?.Quiet == true) - { - return; - } - - Console.WriteLine(); + Instance.Output.WriteLine(ShellMessageKind.Information); } /// - /// Writes the specified message to the standard output stream, using the specified format parameters. + /// Writes an informational message through the shell output policy, using the specified format parameters. /// /// The message to write. /// An array of objects to format. public static void Write(string message, params object[] par) { - if (Instance?.Options?.Quiet == true) - { - return; - } - - Console.Write(message, par); + Instance.Output.Write(ShellMessageKind.Information, message, par); } /// - /// Writes the specified message to the standard output stream. + /// Writes an informational message through the shell output policy. /// /// The message to write. public static void Write(string message) { - if (Instance?.Options?.Quiet == true) - { - return; - } - - Console.Write(message); + Instance.Output.Write(ShellMessageKind.Information, message); } /// @@ -492,6 +500,16 @@ public static void Write(string message) /// true if the user confirms; otherwise, false. public static bool Confirm(string message) { + if (Instance.Options?.McpStdio == true) + { + if (Instance.McpConfirmationApproved) + { + return true; + } + + throw new CommandException("confirm", MessageService.GetString("mcp-error-stdio-console-input")); + } + var yes = char.ToUpper(MessageService.GetString("yes_char")[0]); var no = char.ToUpper(MessageService.GetString("no_char")[0]); @@ -503,7 +521,7 @@ public static bool Confirm(string message) { while (true) { - Console.Write($"{MessageService.GetString(message)} ({yes}/{no})?"); + Instance.Output.Write(ShellMessageKind.RequiredInstruction, $"{MessageService.GetString(message)} ({yes}/{no})?"); ConsoleKeyInfo key; try @@ -514,25 +532,25 @@ public static bool Confirm(string message) { // No interactive console available (e.g. redirected input). Treat as // a declined prompt rather than throwing. - WriteLine(); + Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction); return false; } if (key.Key == ConsoleKey.C && key.Modifiers.HasFlag(ConsoleModifiers.Control)) { - WriteLine("^C"); + Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction, "^C"); return false; } if (key.Key == ConsoleKey.Escape) { - WriteLine(); + Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction); return false; } // intercept:true suppresses the echo, so mirror the keystroke ourselves. - Console.Write(key.KeyChar); - WriteLine(); + Instance.Output.Write(ShellMessageKind.RequiredInstruction, key.KeyChar.ToString()); + Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction); if (char.ToUpper(key.KeyChar) == yes) { @@ -868,7 +886,7 @@ internal static string GetRepositoryUrl(Assembly assembly) internal static void ReportError(string message, params object[] par) { - AnsiConsole.MarkupLine(Theme.FormatError(message), par); + Instance.Output.MarkupLine(ShellMessageKind.Error, Theme.FormatError(message), par); } internal ShellObject GetVariable(string name) @@ -902,61 +920,64 @@ internal ShellObject GetVariable(string name) internal void PrintVersion(CommandState? commandState) { - var isQuiet = this.Options?.Quiet == true; var version = GetDisplayVersion(typeof(VersionCommand).Assembly); var port = this.McpPort; + var stdio = this.Options?.McpStdio == true; var repoUrl = GetRepositoryUrl(typeof(VersionCommand).Assembly); if (commandState == null) { - // Startup banner: render immediately for interactive users. - if (!isQuiet) - { - RenderVersionBanner(version, port, repoUrl); - } - + RenderVersionBanner(this.Output, ShellMessageKind.Information, version, port, repoUrl, stdio); return; } var json = new Dictionary { ["version"] = version, - ["mcpEnabled"] = port != null, + ["mcpEnabled"] = port != null || stdio, ["mcpPort"] = port, // will be null if not enabled - ["mcpStatus"] = port != null ? "on" : "off", + ["mcpStatus"] = port != null || stdio ? "on" : "off", ["repository"] = repoUrl, }; var jsonElement = System.Text.Json.JsonSerializer.SerializeToElement(json); commandState.Result = new ShellJson(jsonElement); - commandState.RenderUser = () => RenderVersionBanner(version, port, repoUrl); + commandState.RenderUser = () => RenderVersionBanner(this.Output, ShellMessageKind.Result, version, port, repoUrl, stdio); } - private static void RenderVersionBanner(string version, int? port, string repoUrl) + private static void RenderVersionBanner(ShellOutput output, ShellMessageKind kind, string version, int? port, string repoUrl, bool stdio) { var versionString = MessageService.GetArgsString("command-version", "version", version); - AnsiConsole.MarkupLine(versionString); + output.MarkupLine(kind, versionString); - if (port != null) + if (stdio) + { + output.MarkupLine(kind, Theme.FormatWarning(MessageService.GetString("command-version-mcp-stdio"))); + } + else if (port != null) { var mcpPortString = MessageService.GetArgsString("command-version-mcp", "mcp_port", port?.ToString() ?? string.Empty); - AnsiConsole.MarkupLine(Theme.FormatWarning(mcpPortString)); + output.MarkupLine(kind, Theme.FormatWarning(mcpPortString)); } else { - AnsiConsole.MarkupLine(MessageService.GetString("command-version-mcp-off")); + output.MarkupLine(kind, MessageService.GetString("command-version-mcp-off")); } if (!string.IsNullOrEmpty(repoUrl)) { var repoString = MessageService.GetArgsString("command-version-repo", "url", repoUrl); - AnsiConsole.MarkupLine(repoString); + output.MarkupLine(kind, repoString); } } internal void ShowWelcome() { - WelcomeScreen.WriteTo(Console.Out); + this.Output.Write(ShellMessageKind.Information, WelcomeScreen.Text + "\u001b[0m"); + if (!WelcomeScreen.Text.EndsWith('\n')) + { + this.Output.WriteLine(ShellMessageKind.Information); + } } internal bool ShowWelcomeOnFirstRun() @@ -1412,39 +1433,47 @@ internal async Task ConnectAsync(string connectionString, string? loginHint = nu // which has its own dedicated step below and honors --tenant there. if (client == null && credentialMethod != CredentialMethod.AzureCli && (!string.IsNullOrWhiteSpace(tenantId) || !string.IsNullOrWhiteSpace(loginHint))) { - var browserOptions = new InteractiveBrowserCredentialOptions + if (AllowsInteractiveBrowser(this.IsMcpStdio)) { - RedirectUri = new Uri(ConnectCommand.EntraRedirectUrl), - }; - if (!string.IsNullOrWhiteSpace(tenantId)) - { - browserOptions.TenantId = tenantId; - } + var browserOptions = new InteractiveBrowserCredentialOptions + { + RedirectUri = new Uri(ConnectCommand.EntraRedirectUrl), + }; + if (!string.IsNullOrWhiteSpace(tenantId)) + { + browserOptions.TenantId = tenantId; + } - if (!string.IsNullOrWhiteSpace(loginHint)) - { - browserOptions.LoginHint = loginHint; - } + if (!string.IsNullOrWhiteSpace(loginHint)) + { + browserOptions.LoginHint = loginHint; + } - if (authorityHostUri != null) - { - browserOptions.AuthorityHost = authorityHostUri; - } + if (authorityHostUri != null) + { + browserOptions.AuthorityHost = authorityHostUri; + } + + WriteLine(MessageService.GetString("shell-connect-browser-auth")); + var browserCredential = new InteractiveBrowserCredential(browserOptions); + if (await this.TryConnectWithTokenCredentialAsync(tokenEndpoint, browserCredential, options, subscriptionId, resourceGroupName, authorityHostUri, allowCredentialFallback: true, token)) + { + return; + } - WriteLine(MessageService.GetString("shell-connect-browser-auth")); - var browserCredential = new InteractiveBrowserCredential(browserOptions); - if (await this.TryConnectWithTokenCredentialAsync(tokenEndpoint, browserCredential, options, subscriptionId, resourceGroupName, authorityHostUri, allowCredentialFallback: true, token)) + // Browser auth failed; fall back to device code. + WriteLine(MessageService.GetString("shell-connect-devicecode-fallback")); + } + else { - return; + this.Output.WriteLine(ShellMessageKind.Information, MessageService.GetString("shell-connect-devicecode-stdio")); } - // Browser auth failed; fall back to device code. - WriteLine(MessageService.GetString("shell-connect-devicecode-fallback")); var deviceCodeOptions = new DeviceCodeCredentialOptions { DeviceCodeCallback = (code, cancellationToken) => { - ShellInterpreter.WriteLine(code.Message); + this.Output.WriteLine(ShellMessageKind.RequiredInstruction, code.Message); return Task.CompletedTask; }, }; @@ -1494,15 +1523,7 @@ internal async Task ConnectAsync(string connectionString, string? loginHint = nu if (client == null) { WriteLine(MessageService.GetString("shell-connect-default-auth")); - var dacOptions = new DefaultAzureCredentialOptions - { - ExcludeInteractiveBrowserCredential = false, - }; - if (authorityHostUri != null) - { - dacOptions.AuthorityHost = authorityHostUri; - } - + var dacOptions = CreateDefaultCredentialOptions(authorityHostUri, AllowsInteractiveBrowser(this.IsMcpStdio)); var dacCredential = new DefaultAzureCredential(dacOptions); // CodeQL [SM05137] Interactive developer CLI, not a hosted service: this is the last-resort fallback that adopts the developer's local identity (Azure CLI/azd, Visual Studio, env vars, or VM managed identity). No fixed service identity exists to pin to. await this.TryConnectWithTokenCredentialAsync(tokenEndpoint, dacCredential, options, subscriptionId, resourceGroupName, authorityHostUri, allowCredentialFallback: false, token); } @@ -1914,6 +1935,11 @@ internal void DisconnectLocalEmulatorAfterConnectivityFailure(Exception exceptio internal void PrintCommand(string cmdString) { + if (this.Options?.McpStdio == true) + { + return; + } + // Print the shell prompt similar to how it appears when typing command // AnsiConsole.Markup(new CosmosShellPrompt(this).GetPromptString()); // AnsiConsole.Write(" "); @@ -1929,18 +1955,23 @@ internal void PrintCommand(string cmdString) System.Diagnostics.Debug.WriteLine(ex); } + if (this.Options?.Quiet == true) + { + return; + } + // Echoing and the line editor both need an ANSI terminal, which an MCP host may not // provide. Neither may fail the command being announced. try { var txt = ((IHighlighter)Instance).BuildHighlightedText(cmdString); - AnsiConsole.Write(txt); - AnsiConsole.WriteLine(); // Ensure the next output starts on a new line + this.Output.Render(ShellMessageKind.Information, txt); + this.Output.WriteLine(ShellMessageKind.Information); // Ensure the next output starts on a new line this.Editor?.History.Add(cmdString); } catch (NotSupportedException) { - Console.Out.WriteLine(cmdString); + this.Output.WriteLine(ShellMessageKind.Information, cmdString); } } @@ -2018,7 +2049,7 @@ internal CommandState PrintState(CommandState state, bool markAsRendered = false var element = (JsonElement?)state.Result.ConvertShellObject(Parser.DataType.Json); if (element.HasValue) { - AnsiConsole.MarkupLine(JsonOutputHighlighter.BuildMarkup(element.Value)); + this.Output.MarkupLine(ShellMessageKind.Result, JsonOutputHighlighter.BuildMarkup(element.Value)); return state; } } @@ -2037,7 +2068,7 @@ internal CommandState PrintState(CommandState state, bool markAsRendered = false && output != null && state.Result is ShellText { Highlighter: { } highlighter }) { - AnsiConsole.MarkupLine(highlighter(output)); + this.Output.MarkupLine(ShellMessageKind.Result, highlighter(output)); return state; } } @@ -2046,7 +2077,7 @@ internal CommandState PrintState(CommandState state, bool markAsRendered = false { if (!redirected) { - Console.Out.WriteLine(output); + this.Output.WriteResult(output); } else { @@ -2058,7 +2089,7 @@ internal CommandState PrintState(CommandState state, bool markAsRendered = false { if (this.Options?.Verbose == true) { - AnsiConsole.WriteException(e); + this.Output.WriteException(ShellMessageKind.Error, e); return new ErrorCommandState(e); } @@ -2078,7 +2109,7 @@ internal CommandState PrintState(CommandState state, bool markAsRendered = false } else if (!string.IsNullOrEmpty(canceled)) { - AnsiConsole.MarkupLine(Theme.FormatWarning(canceled)); + this.Output.MarkupLine(ShellMessageKind.Warning, Theme.FormatWarning(canceled)); } return new ErrorCommandState(e); @@ -2091,16 +2122,16 @@ internal CommandState PrintState(CommandState state, bool markAsRendered = false else { var prefix = MessageService.GetString("runtime-error-prefix") ?? "error"; - AnsiConsole.MarkupLine($"{Theme.FormatError(prefix + ":")} {Markup.Escape(e.Message)}"); + this.Output.MarkupLine(ShellMessageKind.Error, $"{Theme.FormatError(prefix + ":")} {Markup.Escape(e.Message)}"); if (e is IShellExceptionWithHint hinted && !string.IsNullOrEmpty(hinted.Hint)) { - AnsiConsole.MarkupLine(Markup.Escape(hinted.Hint)); + this.Output.MarkupLine(ShellMessageKind.Error, Markup.Escape(hinted.Hint)); } var inner = e.InnerException; while (inner != null) { - AnsiConsole.MarkupLine($" {Theme.FormatError("\u2192")} {Markup.Escape(inner.Message)}"); + this.Output.MarkupLine(ShellMessageKind.Error, $" {Theme.FormatError("\u2192")} {Markup.Escape(inner.Message)}"); inner = inner.InnerException; } } @@ -2236,7 +2267,7 @@ private void PrintSessionRequestChargeWarning(double requestCharge, double warni if (this.IsMachineMode) { - Console.Error.WriteLine(JsonSerializer.Serialize(new Dictionary + this.Output.WriteLine(ShellMessageKind.Warning, JsonSerializer.Serialize(new Dictionary { ["status"] = "warning", ["warning"] = message, @@ -2244,7 +2275,7 @@ private void PrintSessionRequestChargeWarning(double requestCharge, double warni } else { - AnsiConsole.MarkupLine(Theme.FormatWarning(message)); + this.Output.MarkupLine(ShellMessageKind.Warning, Theme.FormatWarning(message)); } } @@ -2274,10 +2305,34 @@ protected virtual void Dispose(bool disposing) private static string? PromptFallback() { - Console.Write(CosmosShellPrompt.PromptMarker + " "); + Instance.Output.Write(ShellMessageKind.RequiredInstruction, CosmosShellPrompt.PromptMarker + " "); return Console.ReadLine(); } + /// + /// Determines whether a sign-in may open a browser. A headless MCP stdio server has + /// no user at the terminal, and the platform browser launcher (for example + /// xdg-open) inherits the process stdout that carries the MCP protocol. + /// + /// Whether the shell runs as an MCP stdio server. + /// when interactive browser sign-in is allowed. + internal static bool AllowsInteractiveBrowser(bool mcpStdio) => !mcpStdio; + + internal static DefaultAzureCredentialOptions CreateDefaultCredentialOptions(Uri? authorityHost, bool allowInteractiveBrowser) + { + var options = new DefaultAzureCredentialOptions + { + ExcludeInteractiveBrowserCredential = !allowInteractiveBrowser, + ExcludeBrokerCredential = !allowInteractiveBrowser, + }; + if (authorityHost != null) + { + options.AuthorityHost = authorityHost; + } + + return options; + } + internal static CosmosClientOptions CreateClientOptions(ConnectionMode requestedMode, bool isEmulator = false) { var options = new CosmosClientOptions @@ -2341,7 +2396,7 @@ internal static CosmosClientOptions CreateClientOptions(ConnectionMode requested } catch (Exception e) { - Console.Error.WriteLine(e.Message); + ShellOutput.StandardError.WriteLine(ShellMessageKind.Error, e.Message); return null; } } @@ -2863,7 +2918,7 @@ private void WriteMachineError(string errorMessage, ShellObject? result = null) } else { - Console.Error.WriteLine(json); + this.Output.WriteLine(ShellMessageKind.Error, json); } } @@ -2888,7 +2943,7 @@ internal static void WriteConnectionError(Exception exception, bool verbose) var cosmos = FindException(exception); if (cosmos != null) { - AnsiConsole.MarkupLine(Theme.FormatError(MessageService.GetArgsString( + Instance.Output.MarkupLine(ShellMessageKind.Error, Theme.FormatError(MessageService.GetArgsString( "shell-connect-error-cosmos-detail", "status", ((int)cosmos.StatusCode).ToString(CultureInfo.InvariantCulture), @@ -2904,20 +2959,20 @@ internal static void WriteConnectionError(Exception exception, bool verbose) // per-credential failure reasons from DefaultAzureCredential's // AuthenticationFailedException and the Cosmos error body/diagnostics // from CosmosException.ToString(). - AnsiConsole.WriteLine(exception.ToString()); + Instance.Output.RenderLine(ShellMessageKind.Error, exception.ToString()); return; } - AnsiConsole.MarkupLine(Theme.FormatError(exception.Message)); + Instance.Output.MarkupLine(ShellMessageKind.Error, Theme.FormatError(exception.Message)); var inner = exception.InnerException; while (inner != null) { - AnsiConsole.MarkupLine($" {Theme.FormatError("\u2192")} {Markup.Escape(inner.Message)}"); + Instance.Output.MarkupLine(ShellMessageKind.Error, $" {Theme.FormatError("\u2192")} {Markup.Escape(inner.Message)}"); inner = inner.InnerException; } - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("shell-connect-verbose-hint"))); + Instance.Output.MarkupLine(ShellMessageKind.Error, Theme.FormatMuted(MessageService.GetString("shell-connect-verbose-hint"))); } private void ReportExecutionError(Exception e, string? sourceText = null) @@ -2982,20 +3037,20 @@ private void ReportExecutionError(Exception e, string? sourceText = null) { if (!string.IsNullOrEmpty(prefix)) { - AnsiConsole.MarkupLine(Markup.Escape(prefix.TrimEnd())); + this.Output.MarkupLine(ShellMessageKind.Error, Markup.Escape(prefix.TrimEnd())); } - AnsiConsole.WriteException(e, new ExceptionSettings + this.Output.WriteException(ShellMessageKind.Error, e, new ExceptionSettings { Format = ExceptionFormats.ShortenPaths, }); } else { - AnsiConsole.MarkupLine(prefix + Theme.FormatError(e.Message)); + this.Output.MarkupLine(ShellMessageKind.Error, prefix + Theme.FormatError(e.Message)); if (!string.IsNullOrEmpty(hint)) { - AnsiConsole.MarkupLine(Markup.Escape(hint)); + this.Output.MarkupLine(ShellMessageKind.Error, Markup.Escape(hint)); } if (showInner) @@ -3003,7 +3058,7 @@ private void ReportExecutionError(Exception e, string? sourceText = null) var inner = e.InnerException; while (inner != null) { - AnsiConsole.MarkupLine($" {Theme.FormatError("->")} {Markup.Escape(inner.Message)}"); + this.Output.MarkupLine(ShellMessageKind.Error, $" {Theme.FormatError("->")} {Markup.Escape(inner.Message)}"); inner = inner.InnerException; } } @@ -3038,7 +3093,7 @@ private void ReportCommandNotFoundError(CommandNotFoundException e, string sourc } else { - AnsiConsole.MarkupLine(Markup.Escape(e.Hint)); + this.Output.MarkupLine(ShellMessageKind.Error, Markup.Escape(e.Hint)); } } @@ -3058,7 +3113,7 @@ private void ReportCommandNotFoundError(CommandNotFoundException e, string sourc } else { - AnsiConsole.MarkupLine(Markup.Escape(csHint)); + this.Output.MarkupLine(ShellMessageKind.Error, Markup.Escape(csHint)); } } } @@ -3119,16 +3174,16 @@ private void ReportPositionalError(PositionalException pe) } else { - AnsiConsole.MarkupLine($"{Markup.Escape($"{pe.FileName}:{pe.Line}:{pe.Column}:")} {Theme.FormatError(MessageService.GetString("runtime-error-prefix") + ":")} {Markup.Escape(pe.Message)}"); + this.Output.MarkupLine(ShellMessageKind.Error, $"{Markup.Escape($"{pe.FileName}:{pe.Line}:{pe.Column}:")} {Theme.FormatError(MessageService.GetString("runtime-error-prefix") + ":")} {Markup.Escape(pe.Message)}"); if (pe.LineText != null) { - AnsiConsole.MarkupLine(" " + Theme.FormatMuted(pe.LineText)); - AnsiConsole.MarkupLine(" " + Theme.FormatError(new string(' ', Math.Max(0, pe.Column - 1)) + "^")); + this.Output.MarkupLine(ShellMessageKind.Error, " " + Theme.FormatMuted(pe.LineText)); + this.Output.MarkupLine(ShellMessageKind.Error, " " + Theme.FormatError(new string(' ', Math.Max(0, pe.Column - 1)) + "^")); } foreach (var frame in callTrace) { - AnsiConsole.MarkupLine(Theme.FormatMuted(frame)); + this.Output.MarkupLine(ShellMessageKind.Error, Theme.FormatMuted(frame)); } } } @@ -3360,16 +3415,16 @@ private void AppendSourceCaretDiagnostic( if (hasOrigin) { var location = $"{origin}:{lineNumber}:{rendered.SourceColumn}:"; - AnsiConsole.MarkupLine($"{Theme.FormatMuted(location)} {FormatLevel(levelPrefix + ":")} {m}"); + this.Output.MarkupLine(isWarning ? ShellMessageKind.Warning : ShellMessageKind.Error, $"{Theme.FormatMuted(location)} {FormatLevel(levelPrefix + ":")} {m}"); } else { - AnsiConsole.MarkupLine($"{FormatLevel(levelPrefix + ":")} {m} {Theme.FormatMuted($"({lineNumber}:{rendered.SourceColumn})")}"); + this.Output.MarkupLine(isWarning ? ShellMessageKind.Warning : ShellMessageKind.Error, $"{FormatLevel(levelPrefix + ":")} {m} {Theme.FormatMuted($"({lineNumber}:{rendered.SourceColumn})")}"); } var gutter = $" > {lineNumber} | "; - AnsiConsole.MarkupLine($"{Theme.FormatMuted(gutter)}{Markup.Escape(rendered.Display)}"); - AnsiConsole.MarkupLine($"{Theme.FormatMuted(new string(' ', gutter.Length) + rendered.CaretLeader)}{FormatLevel(rendered.CaretPad + rendered.CaretMarker)}"); + this.Output.MarkupLine(isWarning ? ShellMessageKind.Warning : ShellMessageKind.Error, $"{Theme.FormatMuted(gutter)}{Markup.Escape(rendered.Display)}"); + this.Output.MarkupLine(isWarning ? ShellMessageKind.Warning : ShellMessageKind.Error, $"{Theme.FormatMuted(new string(' ', gutter.Length) + rendered.CaretLeader)}{FormatLevel(rendered.CaretPad + rendered.CaretMarker)}"); } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellOutput.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellOutput.cs new file mode 100644 index 00000000..7f3bf9c9 --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellOutput.cs @@ -0,0 +1,222 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.Core; + +using Spectre.Console; +using Spectre.Console.Rendering; + +internal enum ShellMessageKind +{ + Information, + Progress, + Warning, + Error, + RequiredInstruction, + Result, +} + +internal sealed class ShellOutput( + Func quiet, + Func stdio, + Func machineMode, + Func? standardOutput = null, + Func? standardError = null, + Func? console = null) +{ + private readonly object consoleCacheLock = new(); + + private TextWriter? cachedPlainWriter; + + private IAnsiConsole? cachedPlainConsole; + + // Early failures and terminal initialization diagnostics always use stderr. + internal static ShellOutput StandardError { get; } = new(() => false, () => false, () => true); + + /// + /// Clears the interactive terminal. Machine mode and MCP stdio have no screen to clear, + /// and emitting terminal control sequences there would corrupt their output streams. + /// + internal void ClearScreen() + { + if (!machineMode() && !stdio()) + { + (console?.Invoke() ?? AnsiConsole.Console).Clear(); + } + } + + /// + /// Writes the interpreter's final, already formatted command result. Unlike + /// presentation, this is the machine-mode + /// stdout contract; MCP stdio returns results through the protocol instead. + /// + internal void WriteResult(string text) + { + if (!stdio()) + { + (standardOutput?.Invoke() ?? Console.Out).WriteLine(text); + } + } + + internal void WriteLine(ShellMessageKind kind, string message = "", params object[] args) + { + if (this.ShouldPresent(kind)) + { + var writer = this.GetWriter(kind); + if (args.Length == 0) + { + writer.WriteLine(message); + } + else + { + writer.WriteLine(message, args); + } + } + } + + internal void WriteLine(ShellMessageKind kind, string message) + { + if (this.ShouldPresent(kind)) + { + this.GetWriter(kind).WriteLine(message); + } + } + + internal void Write(ShellMessageKind kind, string message, params object[] args) + { + if (this.ShouldPresent(kind)) + { + var writer = this.GetWriter(kind); + if (args.Length == 0) + { + writer.Write(message); + } + else + { + writer.Write(message, args); + } + } + } + + internal void Write(ShellMessageKind kind, string message) + { + if (this.ShouldPresent(kind)) + { + this.GetWriter(kind).Write(message); + } + } + + internal void MarkupLine(ShellMessageKind kind, string markup, params object[] args) + { + if (this.ShouldPresent(kind)) + { + var target = this.GetConsole(kind); + if (args.Length == 0) + { + target.MarkupLine(markup); + } + else + { + target.MarkupLine(markup, args); + } + } + } + + internal void MarkupLine(ShellMessageKind kind, string markup) + { + if (this.ShouldPresent(kind)) + { + this.GetConsole(kind).MarkupLine(markup); + } + } + + internal void Markup(ShellMessageKind kind, string markup, params object[] args) + { + if (this.ShouldPresent(kind)) + { + var target = this.GetConsole(kind); + if (args.Length == 0) + { + target.Markup(markup); + } + else + { + target.Markup(markup, args); + } + } + } + + internal void Markup(ShellMessageKind kind, string markup) + { + if (this.ShouldPresent(kind)) + { + this.GetConsole(kind).Markup(markup); + } + } + + internal void RenderLine(ShellMessageKind kind, string text = "") + { + if (this.ShouldPresent(kind)) + { + this.GetConsole(kind).WriteLine(text); + } + } + + internal void Render(ShellMessageKind kind, IRenderable renderable) + { + if (this.ShouldPresent(kind)) + { + this.GetConsole(kind).Write(renderable); + } + } + + internal void WriteException(ShellMessageKind kind, Exception exception, ExceptionSettings? settings = null) + { + if (this.ShouldPresent(kind)) + { + if (machineMode() || stdio()) + { + this.GetWriter(kind).WriteLine(exception); + } + else + { + this.GetConsole(kind).WriteException(exception, settings ?? new ExceptionSettings()); + } + } + } + + private bool ShouldPresent(ShellMessageKind kind) + => OutputPolicy.ShouldPresent(kind, quiet(), stdio(), machineMode()); + + private TextWriter GetWriter(ShellMessageKind kind) + => OutputPolicy.UseStandardError(kind, machineMode(), stdio()) + ? standardError?.Invoke() ?? Console.Error + : standardOutput?.Invoke() ?? Console.Out; + + private IAnsiConsole GetConsole(ShellMessageKind kind) + { + if (!machineMode() && !OutputPolicy.UseStandardError(kind, machineMode(), stdio())) + { + return console?.Invoke() ?? AnsiConsole.Console; + } + + var writer = this.GetWriter(kind); + lock (this.consoleCacheLock) + { + if (!ReferenceEquals(writer, this.cachedPlainWriter) || this.cachedPlainConsole is null) + { + this.cachedPlainWriter = writer; + this.cachedPlainConsole = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Interactive = InteractionSupport.No, + Out = new AnsiConsoleOutput(writer), + }); + } + + return this.cachedPlainConsole; + } + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ClearScreenCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ClearScreenCommand.cs index fcd4426b..64a269b0 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ClearScreenCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ClearScreenCommand.cs @@ -4,13 +4,13 @@ namespace Azure.Data.Cosmos.Shell.KeyBindings; +using Azure.Data.Cosmos.Shell.Core; using RadLine; -using Spectre.Console; internal class ClearScreenCommand : LineEditorCommand { public override void Execute(LineEditorContext context) { - AnsiConsole.Clear(); + ShellInterpreter.Instance.Output.ClearScreen(); } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ReverseSearchHistoryCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ReverseSearchHistoryCommand.cs index 18ea7e21..2c784b54 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ReverseSearchHistoryCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ReverseSearchHistoryCommand.cs @@ -119,12 +119,14 @@ private static ReverseHistorySearchResult FindInitialMatch(IReadOnlyList return isForwardSearch ? ReverseHistorySearch.FindInitialForwardMatch(history, query) : ReverseHistorySearch.FindInitialMatch(history, query); } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CZ0003:Direct console output", Justification = "Line-editor drawing runs only inside the interactive RadLine editor.")] private static void Render(string query, ReverseHistorySearchResult result, bool isForwardSearch, int? promptRow, ShellInterpreter shell) { ClearLine(promptRow); AnsiConsole.Markup(ReverseHistorySearch.FormatSearchPromptMarkup(query, result.Match, result.HasMatch, isForwardSearch, shell, TryGetLineWidth())); } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CZ0003:Direct console output", Justification = "Cursor control for the interactive RadLine editor, not user-facing output.")] private static void ClearLine(int? promptRow) { try diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/LocationResourceSubscriptions.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/LocationResourceSubscriptions.cs index e038769f..26bf7b0f 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/LocationResourceSubscriptions.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/LocationResourceSubscriptions.cs @@ -39,9 +39,16 @@ internal sealed class LocationResourceSubscriptions : BackgroundService private readonly ILogger logger; - public LocationResourceSubscriptions(ILogger logger, IHostApplicationLifetime? lifetime = null) + private readonly bool isStdio; + + private ModelContextProtocol.Server.McpServer? stdioServer; + + private bool stdioSubscribed; + + public LocationResourceSubscriptions(ILogger logger, IHostApplicationLifetime? lifetime = null, bool isStdio = false) { this.logger = logger; + this.isStdio = isStdio; this.stoppingRegistration = lifetime?.ApplicationStopping.Register(this.stopping.Cancel) ?? default; ShellInterpreter.Instance.LocationChanged += this.OnLocationChanged; } @@ -52,7 +59,7 @@ internal int SubscriberCount { lock (this.sync) { - return this.subscribedSessionIds.Count; + return this.subscribedSessionIds.Count + (this.stdioSubscribed ? 1 : 0); } } } @@ -103,9 +110,20 @@ public async Task RunSessionAsync(ModelContextProtocol.Server.McpServer server, } } - public void Subscribe(string? sessionId, string uri) + public void Subscribe(ModelContextProtocol.Server.McpServer server, string uri) { ValidateUri(uri); + lock (this.sync) + { + if (this.isStdio) + { + this.stdioServer = server; + this.stdioSubscribed = true; + return; + } + } + + var sessionId = server.SessionId; if (string.IsNullOrEmpty(sessionId)) { throw new McpProtocolException( @@ -122,9 +140,20 @@ public void Subscribe(string? sessionId, string uri) } } - public void Unsubscribe(string? sessionId, string uri) + public void Unsubscribe(ModelContextProtocol.Server.McpServer server, string uri) { ValidateUri(uri); + lock (this.sync) + { + if (this.isStdio) + { + this.stdioSubscribed = false; + this.stdioServer = null; + return; + } + } + + var sessionId = server.SessionId; if (string.IsNullOrEmpty(sessionId)) { return; @@ -205,6 +234,12 @@ public async ValueTask ListenAsync(RequestContext this.sessions.TryGetValue(sessionId, out var server) ? server : null) .OfType() .ToArray(); + if (this.stdioSubscribed && this.stdioServer is not null) + { + servers = [.. servers, this.stdioServer]; + } + foreach (var stream in this.listenStreams) { stream.Updates.Writer.TryWrite(true); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpServer.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpServer.cs index 5a8a8e6b..71465c7b 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpServer.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/McpServer.cs @@ -21,8 +21,7 @@ namespace Azure.Data.Cosmos.Shell.Mcp; using static Program; /// -/// MCP Server implementation for running shell commands via HTTP. -/// Provides a local HTTP endpoint that accepts command execution requests. +/// MCP Server implementation for running shell commands via HTTP or stdio. /// SECURITY NOTE: This server is designed to run locally only and should not be exposed to external networks. /// internal class McpServer @@ -32,7 +31,7 @@ internal class McpServer public static IHost CreateHost(CosmosShellOptions serverArguments) { var builder = WebApplication.CreateBuilder([]); - ConfigureMcpServer(builder.Services); + ConfigureMcpServer(builder.Services).WithHttpTransport(ConfigureHttpTransport); builder.WebHost .ConfigureKestrel(server => { @@ -50,10 +49,29 @@ public static IHost CreateHost(CosmosShellOptions serverArguments) return application; } - private static void ConfigureMcpServer(IServiceCollection services) + public static IHost CreateStdioHost(Stream input, Stream output) + { + var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings + { + Args = [], + DisableDefaults = true, + }); + builder.Logging.AddConsole(options => options.LogToStandardErrorThreshold = LogLevel.Trace); + builder.Logging.SetMinimumLevel(LogLevel.Warning); + var protocolInput = new StdioInputStream(input); + ConfigureMcpServer(builder.Services, stdio: true).WithStreamServerTransport(protocolInput, output); + var host = builder.Build(); + protocolInput.HostLifetime = host.Services.GetRequiredService(); + return host; + } + + private static IMcpServerBuilder ConfigureMcpServer(IServiceCollection services, bool stdio = false) { services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(services => new LocationResourceSubscriptions( + services.GetRequiredService>(), + services.GetRequiredService(), + stdio)); services.AddHostedService(services => services.GetRequiredService()); services.AddOptions() .Configure((mcpServerOptions, toolOperations, locationSubscriptions) => @@ -89,8 +107,7 @@ private static void ConfigureMcpServer(IServiceCollection services) }); // SDK resource collections advertise list changes even when configured otherwise. - var mcpServerBuilder = services.AddMcpServer(); - mcpServerBuilder.WithHttpTransport(ConfigureHttpTransport); + return services.AddMcpServer(); } internal static void ConfigureHttpTransport(HttpServerTransportOptions options) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/StdioInputStream.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/StdioInputStream.cs new file mode 100644 index 00000000..34a6aba1 --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/StdioInputStream.cs @@ -0,0 +1,82 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.Mcp; + +using Microsoft.Extensions.Hosting; + +internal sealed class StdioInputStream(Stream input) : Stream +{ + public IHostApplicationLifetime? HostLifetime { get; set; } + + public override bool CanRead => input.CanRead; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + var read = input.Read(buffer, offset, count); + this.ObserveRead(read, count); + return read; + } + + public override int Read(Span buffer) + { + var read = input.Read(buffer); + this.ObserveRead(read, buffer.Length); + return read; + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + var read = await input.ReadAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false); + this.ObserveRead(read, count); + return read; + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + var read = await input.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + this.ObserveRead(read, buffer.Length); + return read; + } + + public override void Flush() => throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + input.Dispose(); + } + + base.Dispose(disposing); + } + + private void ObserveRead(int read, int requested) + { + if (read == 0 && requested > 0) + { + // RunAsync can await open subscription handlers after EOF, so shutdown cannot wait for RunAsync. + var lifetime = this.HostLifetime ?? throw new InvalidOperationException("The stdio host lifetime has not been configured."); + lifetime.StopApplication(); + } + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/StdioStartupInput.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/StdioStartupInput.cs new file mode 100644 index 00000000..2e74ca49 --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/StdioStartupInput.cs @@ -0,0 +1,325 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.Mcp; + +using System.IO.Pipelines; +using Azure.Data.Cosmos.Shell.Util; + +internal sealed class StdioStartupInput : IDisposable +{ + internal const int MaximumStartupBufferBytes = 8 * 1024 * 1024; + internal static readonly TimeSpan StartupTimeout = TimeSpan.FromSeconds(60); + + private readonly Stream input; + private readonly Pipe liveBuffer = new(); + private readonly string startupBufferPath = Path.Join(Path.GetTempPath(), $"cosmos-stdio-{Guid.NewGuid():N}.tmp"); + private readonly FileStream startupBuffer; + private readonly SemaphoreSlim startupBufferLock = new(1, 1); + private readonly TaskCompletionSource startupBufferReady = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + private readonly CancellationTokenSource stopping = new(); + private readonly CancellationTokenSource inputEnded = new(); + private readonly CancellationTokenSource deadline = new(); + private readonly CancellationTokenSource startupCancellation; + private readonly Timer deadlineTimer; + private Task pump = Task.CompletedTask; + + private Exception? inputError; + private bool startupBufferCompleted; + private int startupTermination; + private int disposed; + + public StdioStartupInput(Stream input, TimeSpan? timeout = null) + { + this.input = input; + var bufferOptions = new FileStreamOptions + { + Mode = FileMode.CreateNew, + Access = FileAccess.ReadWrite, + Share = FileShare.None, + Options = FileOptions.Asynchronous | FileOptions.SequentialScan | FileOptions.DeleteOnClose, + }; + if (!OperatingSystem.IsWindows()) + { + bufferOptions.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + } + + this.startupBuffer = new FileStream(this.startupBufferPath, bufferOptions); + this.Input = new StartupBufferedStream( + this.startupBufferReady.Task, + this.liveBuffer.Reader.AsStream()); + this.startupCancellation = CancellationTokenSource.CreateLinkedTokenSource( + this.inputEnded.Token, this.deadline.Token); + this.StartupToken = this.startupCancellation.Token; + this.deadlineTimer = new Timer( + static state => ((StdioStartupInput)state!).CancelForTimeout(), + this, + timeout ?? StartupTimeout, + Timeout.InfiniteTimeSpan); + } + + private enum StartupTermination + { + None, + TimedOut, + InputEnded, + InputError, + } + + public Stream Input { get; } + + internal string StartupBufferPath => this.startupBufferPath; + + public CancellationToken StartupToken { get; } + + public Exception? InputError => Volatile.Read(ref this.startupTermination) == (int)StartupTermination.InputError + ? Volatile.Read(ref this.inputError) + : null; + + public bool TimedOut => Volatile.Read(ref this.startupTermination) == (int)StartupTermination.TimedOut; + + public void Start() => this.pump = this.PumpAsync(); + + public async Task CompleteStartupAsync() + { + this.deadlineTimer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + await this.CompleteStartupBufferAsync().ConfigureAwait(false); + } + + public void Dispose() + { + if (Interlocked.Exchange(ref this.disposed, 1) != 0) + { + return; + } + + this.stopping.Cancel(); + + // The stdin pump may never unblock. Release the spool independently of its lifetime. + this.startupBufferLock.Wait(); + try + { + this.startupBufferCompleted = true; + this.startupBuffer.Dispose(); + this.startupBufferReady.TrySetCanceled(this.stopping.Token); + } + finally + { + this.startupBufferLock.Release(); + } + + this.Input.Dispose(); + this.input.Dispose(); + + // Console pipe reads may not be interruptible. Do not hold process shutdown + // open waiting for a read, or dispose its token sources while it is still running. + _ = this.pump.ContinueWith( + _ => + { + this.startupCancellation.Dispose(); + this.deadline.Dispose(); + this.deadlineTimer.Dispose(); + this.inputEnded.Dispose(); + this.stopping.Dispose(); + this.startupBufferLock.Dispose(); + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + private async Task PumpAsync() + { + Exception? error = null; + try + { + var bytes = new byte[4096]; + while (true) + { + var read = await this.input.ReadAsync(bytes, this.stopping.Token).ConfigureAwait(false); + if (read == 0) + { + break; + } + + await this.startupBufferLock.WaitAsync(this.stopping.Token).ConfigureAwait(false); + try + { + if (!this.startupBufferCompleted) + { + if (this.startupBuffer.Length > MaximumStartupBufferBytes - read) + { + throw new IOException(MessageService.GetString("mcp-error-stdio-startup-buffer-limit")); + } + + await this.startupBuffer.WriteAsync( + bytes.AsMemory(0, read), + this.stopping.Token).ConfigureAwait(false); + continue; + } + } + finally + { + this.startupBufferLock.Release(); + } + + await this.liveBuffer.Writer.WriteAsync( + bytes.AsMemory(0, read), + this.stopping.Token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (this.stopping.IsCancellationRequested) + { + // Disposal cancels the input pump; the pipe is completed in the finally block. + } + catch (Exception ex) + { + error = ex; + Volatile.Write(ref this.inputError, ex); + Interlocked.CompareExchange( + ref this.startupTermination, + (int)StartupTermination.InputError, + (int)StartupTermination.None); + } + finally + { + await this.CompleteStartupBufferAsync().ConfigureAwait(false); + await this.liveBuffer.Writer.CompleteAsync(error).ConfigureAwait(false); + if (!this.stopping.IsCancellationRequested) + { + Interlocked.CompareExchange( + ref this.startupTermination, + (int)StartupTermination.InputEnded, + (int)StartupTermination.None); + await this.inputEnded.CancelAsync().ConfigureAwait(false); + } + } + } + + private async Task CompleteStartupBufferAsync() + { + await this.startupBufferLock.WaitAsync().ConfigureAwait(false); + try + { + if (this.startupBufferCompleted) + { + return; + } + + await this.startupBuffer.FlushAsync().ConfigureAwait(false); + this.startupBuffer.Position = 0; + this.startupBufferCompleted = true; + if (!this.startupBufferReady.TrySetResult(this.startupBuffer)) + { + await this.startupBuffer.DisposeAsync().ConfigureAwait(false); + } + } + finally + { + this.startupBufferLock.Release(); + } + } + + private void CancelForTimeout() + { + if (Interlocked.CompareExchange( + ref this.startupTermination, + (int)StartupTermination.TimedOut, + (int)StartupTermination.None) == (int)StartupTermination.None) + { + this.deadline.Cancel(); + } + } + + private sealed class StartupBufferedStream(Task startupBuffer, Stream liveInput) : Stream + { + private Stream? prefix; + private bool prefixCompleted; + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + +#pragma warning disable VSTHRD002 // Synchronously waiting is required by the Stream.Read contract. + public override int Read(byte[] buffer, int offset, int count) + { + if (!this.prefixCompleted) + { + this.prefix ??= startupBuffer.GetAwaiter().GetResult(); + var read = this.prefix.Read(buffer, offset, count); + if (read > 0) + { + return read; + } + + this.prefixCompleted = true; + this.prefix.Dispose(); + } + + return liveInput.Read(buffer, offset, count); + } +#pragma warning restore VSTHRD002 + + public override async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + { + if (!this.prefixCompleted) + { + this.prefix ??= await startupBuffer.WaitAsync(cancellationToken).ConfigureAwait(false); + var read = await this.prefix.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read > 0) + { + return read; + } + + this.prefixCompleted = true; + await this.prefix.DisposeAsync().ConfigureAwait(false); + } + + return await liveInput.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + this.prefix?.Dispose(); + liveInput.Dispose(); + if (this.prefix is null) + { + _ = startupBuffer.ContinueWith( + static task => task.Result.Dispose(), + CancellationToken.None, + TaskContinuationOptions.OnlyOnRanToCompletion | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + } + + base.Dispose(disposing); + } + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ToolOperations.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ToolOperations.cs index 5389c86a..51f40e32 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ToolOperations.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Mcp/ToolOperations.cs @@ -65,16 +65,23 @@ internal delegate ValueTask ConfirmationPrompt( private ValueTask SubscribeToResourcesAsync(RequestContext context, CancellationToken cancellationToken) { - this.locationSubscriptions.Subscribe(context.Server.SessionId, context.Params?.Uri ?? string.Empty); + this.locationSubscriptions.Subscribe(context.Server, context.Params?.Uri ?? string.Empty); return ValueTask.FromResult(new EmptyResult()); } private ValueTask UnsubscribeFromResourcesAsync(RequestContext context, CancellationToken cancellationToken) { - this.locationSubscriptions.Unsubscribe(context.Server.SessionId, context.Params?.Uri ?? string.Empty); + this.locationSubscriptions.Unsubscribe(context.Server, context.Params?.Uri ?? string.Empty); return ValueTask.FromResult(new EmptyResult()); } + /// + /// Determines whether a command cannot run in MCP stdio mode. + /// + internal static bool IsUnavailableInStdio(CommandFactory command) + => command.IsExternal || command.CommandName == "theme" + || (command.McpRestricted && !RequiresConfirmation(command)); + internal static Tool GetTool(CommandFactory command) { var descriptionParts = new[] { command.Description, command.McpDescription } @@ -713,6 +720,10 @@ private async ValueTask OnCallToolsAsync( }, requestState: ConfirmationRequestState.Create(commandLine, stateVersion)); } + else if (clientCapabilities?.Elicitation != null && server is not null) + { + prompt = (request, _, token) => server.ElicitAsync(request, token); + } return await this.ExecuteToolAsync(command, cmd, commandLine, prompt, cancellationToken, parameters.Params); } @@ -758,12 +769,26 @@ internal async Task ExecuteToolAsync( } shell.PrintCommand(commandLine); - var response = await shell.ExecuteCosmosCommandAsync(cmd, new CommandState(), command.CommandName, cancellationToken); - shell.CancelPrompt(); - return McpResponseFactory.CreateSuccess(response, shell.State); + var previousApproval = shell.McpConfirmationApproved; + shell.McpConfirmationApproved = confirmedVersion.HasValue; + try + { + var response = await shell.ExecuteCosmosCommandAsync(cmd, new CommandState(), command.CommandName, cancellationToken); + shell.CancelPrompt(); + return McpResponseFactory.CreateSuccess(response, shell.State); + } + finally + { + shell.McpConfirmationApproved = previousApproval; + } }, cancellationToken); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + this.logger?.LogInformation("MCP command '{Command}' was cancelled.", command.CommandName); + throw; + } catch (Exception ex) { this.logger?.LogError(ex, $"An exception occurred running '{command.CommandName}'. "); diff --git a/CosmosDBShell/GlobalSuppressions.cs b/CosmosDBShell/GlobalSuppressions.cs index 0e73943e..5b02218d 100644 --- a/CosmosDBShell/GlobalSuppressions.cs +++ b/CosmosDBShell/GlobalSuppressions.cs @@ -8,11 +8,6 @@ // a specific target and scoped to a namespace, type, member, etc. using System.Diagnostics.CodeAnalysis; -[assembly: SuppressMessage("Naming", "CZ0002:WriteLine", Justification = "Intentional I need to use Console.WriteLine at one point.", Scope = "member", Target = "~M:Azure.Data.Cosmos.Shell.Core.ShellInterpreter.WriteLine(System.String,System.Object[])")] -[assembly: SuppressMessage("Naming", "CZ0002:WriteLine", Justification = "Intentional I need to use Console.WriteLine at one point.", Scope = "member", Target = "~M:Azure.Data.Cosmos.Shell.Core.ShellInterpreter.WriteLine(System.String)")] -[assembly: SuppressMessage("Naming", "CZ0002:WriteLine", Justification = "Intentional I need to use Console.WriteLine at one point.", Scope = "member", Target = "~M:Azure.Data.Cosmos.Shell.Core.ShellInterpreter.WriteLine")] -[assembly: SuppressMessage("Naming", "CZ0002:Write", Justification = "Intentional I need to use Console.WriteLine at one point.", Scope = "member", Target = "~M:Azure.Data.Cosmos.Shell.Core.ShellInterpreter.Write(System.String,System.Object[])")] -[assembly: SuppressMessage("Naming", "CZ0002:Write", Justification = "Intentional I need to use Console.WriteLine at one point.", Scope = "member", Target = "~M:Azure.Data.Cosmos.Shell.Core.ShellInterpreter.Write(System.String)")] [assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA0001:XML comment analysis is disabled due to project configuration", Justification = "CLI tool does not ship XML documentation.")] [assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:Elements should be documented", Justification = "No API, but required public for the localization system.", Scope = "type", Target = "~T:Azure.Data.Cosmos.Shell.Util.LocalizableSentenceBuilder")] [assembly: SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1202:Elements should be ordered by access", Justification = "Internal members exposed only for testing are kept next to their private callers.")] diff --git a/CosmosDBShell/Program.cs b/CosmosDBShell/Program.cs index faf5fc6b..794ada1b 100644 --- a/CosmosDBShell/Program.cs +++ b/CosmosDBShell/Program.cs @@ -25,6 +25,53 @@ public static async Task Main(string[] args) // accidentally trigger LSP mode. args = NormalizeArguments(args); + var preCommandArgs = TakePreCommandArgs(args); + var directStdioTokens = preCommandArgs.Where(a => a == "--mcp-stdio" + || a.StartsWith("--mcp-stdio=", StringComparison.Ordinal) + || a.StartsWith("--mcp-stdio:", StringComparison.Ordinal)).ToArray(); + bool stdioRequested = directStdioTokens.Length > 0; + var (rootCommand, optionMap) = BuildRootCommand(); + var configuration = new System.CommandLine.CommandLineConfiguration( + rootCommand, + resources: new LocalizedCliResources()); + var parser = new System.CommandLine.Parsing.Parser(configuration); + var parseResult = parser.Parse(args); + bool stdioDirectlyEnabled = directStdioTokens.Any(IsDirectlyEnabledMcpStdioToken); + + // Response files are expanded before any protocol-mode early return so a hidden + // stdio request cannot bypass the direct-command-line transport requirement. + if (parseResult.GetValueForOption(optionMap.McpStdio) && !stdioDirectlyEnabled) + { + WriteErrorLine(MessageService.GetString("mcp-error-stdio-response-file")); + Environment.ExitCode = ShellExitCode.UsageError; + return; + } + + using var protocolInput = stdioRequested ? Console.OpenStandardInput() : null; + using var protocolOutput = stdioRequested ? Console.OpenStandardOutput() : null; + if (stdioRequested) + { + ShellInterpreter.ConfigureStdioStartup(); + + // Only the transport may use the original streams; commands cannot consume MCP input. + Console.SetOut(Console.Error); + Console.SetIn(TextReader.Null); + AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Interactive = InteractionSupport.No, + Out = new AnsiConsoleOutput(Console.Error), + }); + + if (preCommandArgs.Any(a => a is "--lsp" or "--stdio")) + { + WriteErrorLine(MessageService.GetString("mcp-error-stdio-incompatible-options")); + Environment.ExitCode = ShellExitCode.UsageError; + return; + } + } + // Handle LSP mode early, before any other code can write to stdout. // The LSP protocol requires exclusive access to stdin/stdout. Only // inspect the prefix before -c / -k so that a command tail of literally @@ -47,26 +94,18 @@ public static async Task Main(string[] args) // before -c / -k so that a command tail of literally "--help" or // "--version" (e.g. `-c --help`) is forwarded to the shell command // instead of intercepted here. - var preCommandArgs = TakePreCommandArgs(args); if (preCommandArgs.Any(a => a is "--help" or "-h" or "-?" or "/?" or "/h")) { - ShellInterpreter.WriteLine(BuildHelpText()); + WriteStartupResult(BuildHelpText(), stdioRequested); return; } if (preCommandArgs.Any(a => a is "--version")) { - WriteVersionHeading(); + WriteVersionHeading(stdioRequested); return; } - var (rootCommand, optionMap) = BuildRootCommand(); - var configuration = new System.CommandLine.CommandLineConfiguration( - rootCommand, - resources: new LocalizedCliResources()); - var parser = new System.CommandLine.Parsing.Parser(configuration); - var parseResult = parser.Parse(args); - if (parseResult.Errors.Count > 0) { var outputMode = parseResult.GetValueForOption(optionMap.Output); @@ -74,7 +113,7 @@ public static async Task Main(string[] args) var isNonInteractive = !string.IsNullOrWhiteSpace( parseResult.GetValueForOption(optionMap.ExecuteAndQuit)); - var isMachineMode = OutputPolicy.IsMachineMode(outputMode, quiet, isNonInteractive); + var isMachineMode = stdioRequested || OutputPolicy.IsMachineMode(outputMode, quiet, isNonInteractive); if (isMachineMode) { var errorStrings = parseResult.Errors.Select(e => e.Message).ToList(); @@ -89,10 +128,10 @@ public static async Task Main(string[] args) { foreach (var error in parseResult.Errors) { - ShellInterpreter.WriteLine(error.Message); + ShellOutput.StandardError.WriteLine(ShellMessageKind.Error, error.Message); } - ShellInterpreter.WriteLine(BuildHelpText()); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, BuildHelpText()); } Environment.ExitCode = ShellExitCode.UsageError; @@ -119,6 +158,7 @@ public static async Task Main(string[] args) ConnectAzureCli = parseResult.GetValueForOption(optionMap.ConnectAzureCli), Database = parseResult.GetValueForOption(optionMap.Database), Container = parseResult.GetValueForOption(optionMap.Container), + McpStdio = parseResult.GetValueForOption(optionMap.McpStdio), StartLspServer = parseResult.GetValueForOption(optionMap.StartLspServer), LspStdio = parseResult.GetValueForOption(optionMap.LspStdio), Verbose = parseResult.GetValueForOption(optionMap.Verbose), @@ -134,6 +174,18 @@ public static async Task Main(string[] args) o.McpPort = mcpValue ?? DefaultMcpPort; } + if (stdioRequested && (!o.McpStdio || o.McpPort.HasValue + || parseResult.FindResultFor(optionMap.ExecuteAndQuit) is not null + || parseResult.FindResultFor(optionMap.ExecuteAndContinue) is not null + || o.ClearHistory + || o.StartLspServer + || o.LspStdio)) + { + WriteErrorLine(MessageService.GetString("mcp-error-stdio-incompatible-options")); + Environment.ExitCode = ShellExitCode.UsageError; + return; + } + // --diagnostics supports an optional value: when present without a path, // logging is written to a timestamped file in the config directory. var diagnosticsResult = parseResult.FindResultFor(optionMap.Diagnostics); @@ -174,7 +226,7 @@ public static async Task Main(string[] args) } else { - ShellInterpreter.WriteLine(msg); + ShellOutput.StandardError.WriteLine(ShellMessageKind.Error, msg); } return; @@ -206,7 +258,7 @@ public static async Task Main(string[] args) } else { - ShellInterpreter.WriteLine(MessageService.GetString("error-mutually-exclusive-options")); + ShellOutput.StandardError.WriteLine(ShellMessageKind.Error, MessageService.GetString("error-mutually-exclusive-options")); } return; @@ -221,7 +273,7 @@ public static async Task Main(string[] args) o.Output = "json"; } - var startupMachineMode = OutputPolicy.IsMachineMode( + var startupMachineMode = o.McpStdio || OutputPolicy.IsMachineMode( o.Output, o.Quiet, !string.IsNullOrWhiteSpace(executeAndQuitCommand)); void WriteStartupError(string message) @@ -237,7 +289,7 @@ void WriteStartupError(string message) return; } - AnsiConsole.WriteLine(message); + ShellOutput.StandardError.WriteLine(ShellMessageKind.Error, message); } if (o.Container != null && o.Database == null) @@ -279,7 +331,9 @@ void WriteStartupError(string message) if (startupMachineMode) { colorSystemVal = 0; // Force NoColors in machine mode - o.Quiet = true; // Suppress informational messages to keep output clean + + // Stdio diagnostics may use stderr, including device-code login instructions. + o.Quiet = !o.McpStdio || o.Quiet; } AnsiConsole.Profile.Capabilities.ColorSystem = colorSystemVal switch @@ -289,22 +343,22 @@ void WriteStartupError(string message) _ => ColorSystem.NoColors, }; - ApplyTheme(o.Theme, startupMachineMode); + ShellInterpreter.Instance.Options = o; + ApplyTheme(o.Theme); if (startupMachineMode) { - // Keep machine-mode stdout deterministic even if a command - // accidentally writes via AnsiConsole instead of command state output. + // Safety net for dependency writes through the global Spectre console: + // machine-mode stdout carries only interpreter results. AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings { Ansi = AnsiSupport.No, ColorSystem = ColorSystemSupport.NoColors, - Out = new AnsiConsoleOutput(TextWriter.Null), + Interactive = InteractionSupport.No, + Out = new AnsiConsoleOutput(o.McpStdio ? Console.Error : TextWriter.Null), }); } - ShellInterpreter.Instance.Options = o; - // Enable diagnostic logging before connecting so the startup --connect // event is captured in the log. if (o.EnableDiagnostics) @@ -319,14 +373,20 @@ void WriteStartupError(string message) tracing = TracingBootstrap.Initialize(o.OtlpEndpoint); } + using var startupInput = o.McpStdio && o.ConnectionString != null + ? new StdioStartupInput(protocolInput!) + : null; + if (o.ConnectionString != null) { using var connectTokenSource = ShellInterpreter.UserCancellationTokenSource; - var connectToken = connectTokenSource.Token; + using var startupConnectCancellation = CancellationTokenSource.CreateLinkedTokenSource( + connectTokenSource.Token, startupInput?.StartupToken ?? CancellationToken.None); + var connectToken = startupConnectCancellation.Token; try { var credentialMethod = ShellInterpreter.ResolveCredentialMethod(o.ConnectVSCodeCredential, o.ConnectAzureCli); - await ShellInterpreter.Instance.ConnectAsync( + var connectTask = ShellInterpreter.Instance.ConnectAsync( o.ConnectionString, o.ConnectHint, o.ConnectionMode, @@ -337,16 +397,21 @@ await ShellInterpreter.Instance.ConnectAsync( subscriptionId: o.ConnectSubscription, resourceGroupName: o.ConnectResourceGroup, token: connectToken); + + // Preserve connection argument validation even when stdin is already closed. + startupInput?.Start(); + await connectTask; } catch (OperationCanceledException) when (connectToken.IsCancellationRequested) { + ReportStdioStartupCancellation(startupInput, WriteStartupError); return; } catch (Exception ex) { Environment.ExitCode = ShellExitCode.FromException(ex); - var inMachineMode = OutputPolicy.IsMachineMode( + var inMachineMode = o.McpStdio || OutputPolicy.IsMachineMode( o.Output, o.Quiet, !string.IsNullOrWhiteSpace(o.ExecuteAndQuit)); if (!inMachineMode @@ -376,8 +441,10 @@ await ShellInterpreter.Instance.ConnectAsync( if (o.Database != null) { - var navigationTokenSource = ShellInterpreter.UserCancellationTokenSource; - var navigationToken = navigationTokenSource.Token; + using var navigationTokenSource = ShellInterpreter.UserCancellationTokenSource; + using var startupNavigationCancellation = CancellationTokenSource.CreateLinkedTokenSource( + navigationTokenSource.Token, startupInput?.StartupToken ?? CancellationToken.None); + var navigationToken = startupNavigationCancellation.Token; try { var navigation = new CdCommand @@ -390,6 +457,7 @@ await ShellInterpreter.Instance.ConnectAsync( } catch (OperationCanceledException) when (navigationToken.IsCancellationRequested) { + ReportStdioStartupCancellation(startupInput, WriteStartupError); return; } catch (CommandException ex) @@ -400,6 +468,34 @@ await ShellInterpreter.Instance.ConnectAsync( } } + if (o.McpStdio) + { + if (startupInput is not null) + { + await startupInput.CompleteStartupAsync(); + } + + if (startupInput?.StartupToken.IsCancellationRequested == true) + { + ReportStdioStartupCancellation(startupInput, WriteStartupError); + return; + } + + try + { + ShellInterpreter.Instance.App.RemoveCommands(ToolOperations.IsUnavailableInStdio); + host = McpServer.CreateStdioHost(startupInput?.Input ?? protocolInput!, protocolOutput!); + await host.RunAsync(); + } + catch (Exception ex) + { + WriteStartupError(MessageService.GetArgsString("mcp-error-server-failed-start", "message", ex.Message)); + Environment.ExitCode = ShellExitCode.GeneralFailure; + } + + return; + } + // Start MCP server if requested Task? hostTask = null; @@ -505,7 +601,21 @@ await ShellInterpreter.Instance.ConnectAsync( } } - private static void WriteVersionHeading() + internal static void ReportStdioStartupCancellation(StdioStartupInput? input, Action writeError) + { + if (input?.InputError is { } error) + { + writeError(MessageService.GetArgsString("mcp-error-stdio-input", "message", error.Message)); + Environment.ExitCode = ShellExitCode.FromException(error); + } + else if (input?.TimedOut == true) + { + writeError(MessageService.GetString("mcp-error-stdio-startup-timeout")); + Environment.ExitCode = ShellExitCode.ConnectionError; + } + } + + private static void WriteVersionHeading(bool stdio = false) { var assembly = typeof(Program).Assembly; var product = assembly.GetCustomAttribute()?.Product; @@ -519,7 +629,21 @@ private static void WriteVersionHeading() var heading = string.IsNullOrEmpty(commit) ? $"{product} {version}" : $"{product} {version} ({commit})"; - ShellInterpreter.WriteLine(heading); + WriteStartupResult(heading, stdio); + } + + // Explicit --help/--version output is the requested result; in stdio mode it must + // stay off the protocol stream. + private static void WriteStartupResult(string text, bool stdio) + { + if (stdio) + { + ShellOutput.StandardError.WriteLine(ShellMessageKind.Information, text); + } + else + { + ShellInterpreter.Instance.Output.WriteResult(text); + } } private static async Task StopHostAsync(IHost? host, Task? hostTask) @@ -605,6 +729,16 @@ internal static string[] TakePreCommandArgs(string[] args) return args; } + private static bool IsDirectlyEnabledMcpStdioToken(string token) + { + if (token == "--mcp-stdio") + { + return true; + } + + return bool.TryParse(token["--mcp-stdio=".Length..], out var enabled) && enabled; + } + private static (RootCommand Command, OptionMap Map) BuildRootCommand() { var colorSystem = new Option( @@ -692,6 +826,7 @@ private static (RootCommand Command, OptionMap Map) BuildRootCommand() { Arity = ArgumentArity.ZeroOrOne, }; + var mcpStdio = new Option("--mcp-stdio", MessageService.GetString("help-McpStdio")); var startLspServer = new Option("--lsp", MessageService.GetString("help-EnableLspServer")); var lspStdio = new Option("--stdio", MessageService.GetString("help-EnableLspServer")) @@ -731,6 +866,7 @@ private static (RootCommand Command, OptionMap Map) BuildRootCommand() database, container, mcpPort, + mcpStdio, startLspServer, lspStdio, verbose, @@ -759,6 +895,7 @@ private static (RootCommand Command, OptionMap Map) BuildRootCommand() database, container, mcpPort, + mcpStdio, startLspServer, lspStdio, verbose, @@ -771,7 +908,7 @@ private static (RootCommand Command, OptionMap Map) BuildRootCommand() private static void WriteErrorLine(string message) { - Console.Error.WriteLine(message); + ShellOutput.StandardError.WriteLine(ShellMessageKind.Error, message); } private static string BuildHelpText() @@ -847,9 +984,9 @@ private static string BuildHelpText() /// Resolves the requested theme profile and applies it via . /// Resolution order: explicit --theme flag, then COSMOSDB_SHELL_THEME /// environment variable, then the built-in default. Unknown names emit a warning - /// to standard output and fall back to the default profile. + /// and fall back to the default profile. /// - private static void ApplyTheme(string? themeFromCli, bool suppressStartupWarnings) + private static void ApplyTheme(string? themeFromCli) { // Always scan the user themes directory so file-loaded themes are visible // to --theme, the COSMOSDB_SHELL_THEME env var, and the in-shell `theme` @@ -859,10 +996,7 @@ private static void ApplyTheme(string? themeFromCli, bool suppressStartupWarning registry.LoadFromDirectory(ThemeFile.DefaultUserThemesDirectory()); foreach (var warning in registry.Warnings) { - if (!suppressStartupWarnings) - { - ShellInterpreter.WriteLine(warning); - } + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Warning, warning); } var requested = !string.IsNullOrWhiteSpace(themeFromCli) @@ -876,15 +1010,12 @@ private static void ApplyTheme(string? themeFromCli, bool suppressStartupWarning if (!ThemeProfiles.TryGet(requested, out var profile)) { - if (!suppressStartupWarnings) - { - ShellInterpreter.WriteLine(MessageService.GetArgsString( - "warning-unknown-theme", - "name", - requested, - "themes", - string.Join(", ", registry.All.Keys))); - } + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Warning, MessageService.GetArgsString( + "warning-unknown-theme", + "name", + requested, + "themes", + string.Join(", ", registry.All.Keys))); } Theme.Apply(profile); @@ -910,6 +1041,7 @@ private sealed record OptionMap( Option Database, Option Container, Option McpPort, + Option McpStdio, Option StartLspServer, Option LspStdio, Option Verbose, @@ -996,6 +1128,8 @@ public class CosmosShellOptions public int? McpPort { get; set; } + public bool McpStdio { get; set; } + public bool StartLspServer { get; set; } public bool LspStdio { get; set; } diff --git a/CosmosDBShell/lang/en.ftl b/CosmosDBShell/lang/en.ftl index d371835a..b4e477b1 100644 --- a/CosmosDBShell/lang/en.ftl +++ b/CosmosDBShell/lang/en.ftl @@ -132,6 +132,7 @@ shell-connect-static-token-expiry = Expires in { $timespan } (expiration: { $exp shell-connect-vscode-credential-auth = Connecting with Visual Studio Code credential... shell-connect-vscode-credential-fallback = Visual Studio Code credential unavailable, falling back... shell-connect-devicecode-fallback = Browser authentication failed, falling back to device code authentication... +shell-connect-devicecode-stdio = Browser sign-in is unavailable in MCP stdio mode. Using device code sign-in instead. shell-connect-arm-discovery-failed = Using Cosmos DB data plane. shell-connect-arm-discovery-ambiguous = Multiple ARM Cosmos DB accounts match the connected endpoint. Reconnect with --subscription and --resource-group, or use --connect-subscription and --connect-resource-group at startup, to specify which account to use. Using Cosmos DB data plane for now. shell-connect-verbose-hint = Run with --verbose for full exception details. @@ -212,7 +213,7 @@ help-examples = Examples: help-examples-heading = Examples help-aliases = Aliases: help-OutputFormat = The output format to use (user, json, table, csv). -help-Quiet = Suppresses standard informational output. +help-Quiet = Suppress informational messages, progress, banners, and command echoes. Preserve results, warnings, errors, and required authentication instructions. command-help-description = Shows help information for commands command-welcome-description = Displays the welcome screen @@ -796,7 +797,7 @@ command-results-limit_reached = command-watch-description = Tail the change feed of a container, streaming new and modified items as they arrive. command-watch-description-from-beginning = Start from the beginning of the change feed instead of from now. command-watch-description-partition-key = Scope the change feed to a single partition key value. -command-watch-description-max = Stop after receiving this many changes. Omit, or use 0 or a negative value, to follow until Ctrl+C. +command-watch-description-max = Stop after receiving this many changes. Required and positive in machine mode. In interactive mode, omit or use 0 or a negative value to follow until Ctrl+C. command-watch-description-interval = Seconds to wait between change feed polls once caught up. Defaults to 1; values below 0.1 are clamped. command-watch-description-format = { command-query-description-format } command-watch-description-database = The database containing the container to watch. @@ -805,6 +806,7 @@ command-watch-started = Watching changes in { $container }. Press Ctrl+C to stop command-watch-stopped = Stopped watching after { $count } changes. command-watch-error-request_failed = Change feed request failed with status code { $statusCode } ({ $status }). command-watch-error-invalid_interval = Interval value '{ $interval }' is invalid. Specify a finite number of seconds. +command-watch-error-machine_max_required = Machine-mode watch requires a positive --max value. Use interactive mode to follow changes without a limit. command-jq-description = Commandline JSON processor command-jq-description-args = Arguments for the jq command @@ -1186,6 +1188,14 @@ help-StartupContainer = Navigate to this container after connecting at startup. help-EnableMcpServer = Enable MCP server for programmatic control of the shell help-EnableLspServer = Enable Language Server Protocol (LSP) server for editor integration help-McpPort = Enable MCP HTTP server. Optionally specify a port with --mcp ; default is 6128. +help-McpStdio = Run a headless MCP server over stdin/stdout, with diagnostics on stderr and no listening port. +command-version-mcp-stdio = MCP server: stdio +mcp-error-stdio-incompatible-options = Error: --mcp-stdio cannot be combined with --mcp, --lsp, --stdio, -c, -k, or --clear-history, and must be enabled. +mcp-error-stdio-response-file = Error: --mcp-stdio must be passed directly on the command line, not through a response file. +mcp-error-stdio-console-input = Interactive console input is unavailable in MCP stdio mode. Use MCP elicitation for confirmation. +mcp-error-stdio-startup-timeout = MCP stdio startup connection and navigation timed out after 60 seconds. Use a non-interactive credential or connect through an MCP tool after starting the server. +mcp-error-stdio-input = MCP stdio input failed: { $message } +mcp-error-stdio-startup-buffer-limit = Startup input exceeded the 8 MiB buffering limit. Wait for startup to finish before sending more requests. help-Verbose = Print full exception details instead of only the message. help-Theme = Color theme profile to apply at startup. Falls back to the COSMOSDB_SHELL_THEME environment variable. help-Diagnostics = Write timestamped diagnostic logs to a file. Optionally specify a path with --diagnostics ; defaults to a timestamped file in the shell configuration directory. diff --git a/README.md b/README.md index 48444bd1..fcc1b7f1 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,24 @@ A terminal-native shell for Azure Cosmos DB — navigate databases like a filesy Exports replace their destination only after successful completion, preserving an existing file on failure or cancellation. Imports stream records; CSV exports use temporary disk storage to discover columns without retaining all documents in memory. Scalar CSV results use an empty column header by default; set `COSMOSDB_SHELL_CSV_SCALAR_COLUMN` to name it. An import rejects populated columns with empty headers rather than discarding their values. See [import/export](docs/commands.md#export). -MCP command execution is serialized with the shell, and destructive confirmations are invalidated by connection or navigation changes. Ordinary explicit null MCP arguments are omitted; null continuation tokens and null `rm` partition-key/ETag safety options are rejected. MCP invocations are echoed in the shell so their activity stays visible, and they are recorded in history alongside interactive commands. Concurrent shells merge history under a shared lock and publish complete replacements instead of truncating the saved file. History remains fully replayable, including connection strings; treat its file as sensitive. See [MCP security](docs/mcp.md#security) and [history](docs/navigation.md#history). +MCP command execution is serialized with the shell, and destructive confirmations are invalidated by connection or navigation changes. Ordinary explicit null MCP arguments are omitted; null continuation tokens and null `rm` partition-key/ETag safety options are rejected. HTTP MCP invocations are echoed in the shell and recorded in history. Stdio mode neither loads existing history nor records commands. Concurrent interactive/HTTP shells merge history under a shared lock and publish complete replacements instead of truncating the saved file. History remains fully replayable, including connection strings; treat its file as sensitive. See [MCP security](docs/mcp.md#security) and [history](docs/navigation.md#history). + +Use `cosmosdbshell --mcp-stdio` for a client-owned, headless MCP process without a +listening port. Stdin and stdout are reserved exclusively for MCP; diagnostics go +to stderr, commands are not echoed, and closing stdin stops the server. +Startup connection and navigation have a shared 60-second timeout and are +cancelled when stdin closes; timeout errors go to stderr with exit code 4. +Interactive-only commands, `jq`, and `theme` are omitted from stdio tools and help; +destructive commands remain available through MCP confirmation. Existing +`--mcp [port]` HTTP mode remains available alongside the interactive shell. See +[stdio setup](docs/mcp.md#stdio-headless-mode). MCP clients supporting resource subscriptions can watch `cosmos://shell/current-location` for interactive navigation and connection changes; the resource includes the current account endpoint separately from the location. The resource list is static, so the server does not advertise `resources.listChanged`. See [MCP location updates](docs/mcp.md#shell-location-updates). +MCP stdio startup buffers at most 8 MiB of protocol input. Exceeding that limit cancels startup with an error on stderr and exit code `1`; it does not limit normal transport after startup. + +Machine-mode `watch` requires a positive `--max` and returns the collected changes as its final result; interactive watches can still follow without a limit. See [watch](docs/commands.md#watch). + ## Quick Start **Requirements:** .NET SDK 10.0+. @@ -201,7 +215,7 @@ Packaging runs produce preview versions in the form `1.0.-preview.` | Option | Description | | ------ | ----------- | | `--output ` | The output format to use (`user`, `json`, `table`, `csv`). Alias: `-o`. Falls back to `COSMOSDB_SHELL_FORMAT`. | -| `--quiet` | Suppress standard informational output. Alias: `-q` | +| `--quiet` | Suppress informational messages, progress, banners, and command echoes; preserve results, warnings, errors, and required authentication instructions. Alias: `-q` | | `-c ` | Execute and exit | | `-k ` | Execute and stay | | `--connect ` | Connection string or endpoint URL | @@ -215,6 +229,7 @@ Packaging runs produce preview versions in the form `1.0.-preview.` | `--database ` | Navigate to this database after connecting at startup | | `--container ` | Navigate to this container after connecting at startup. Requires `--database` | | `--mcp [port]` | Enable MCP server on the given port, or `6128` by default | +| `--mcp-stdio` | Run headless MCP over stdin/stdout; diagnostics use stderr and no port is opened | | `--diagnostics [path]` | Write timestamped diagnostic logs to a file, or to a timestamped file in the config directory by default | | `--otel [endpoint]` | Enable distributed tracing (sampled W3C `traceparent`); optional OTLP `endpoint`, else `OTEL_EXPORTER_OTLP_ENDPOINT` | | `--verbose` | Print full exception details | @@ -273,7 +288,7 @@ When running scripts or automation, Cosmos DB Shell maps execution failures to a These values are a public contract: existing codes are never repurposed, and new failure categories get new codes. See the [CI/CD guide](docs/ci.md#exit-code-contract) for full details. -> **Machine Mode**: Using `--output json` or `--output csv` or `--quiet` (or running `-c` without specifying `--output`, which defaults to `json`) disables ANSI colors, suppresses connection/informational banners, and redirects early parser/connection exceptions to `STDERR` as structured JSON. Data-operation commands emit their result in the selected structured format (JSON or CSV) on `STDOUT`; diagnostic and interactive commands may still write plain text. Interactively, commands render a friendly, human-readable view instead; the default `user` output format selects that friendly view and automatically falls back to JSON whenever output is redirected, piped, or run in machine mode. Bare piped stdin (`echo "..." | cosmosdbshell`) is not implicitly machine mode—pass `-c`, `--output json`, or `--quiet` to opt in. +> **Machine Mode**: Using `--output json` or `--output csv` or `--quiet` (or running `-c` without specifying `--output`, which defaults to `json`) disables ANSI colors, suppresses connection/informational banners, and redirects parser, connection, and command failures to `STDERR` as structured JSON. Data-operation commands emit their result in the selected structured format (JSON or CSV) on `STDOUT`; diagnostic and interactive commands may still write plain text. Warnings, errors, and required authentication instructions remain visible on `STDERR`, even with `--quiet`; explicitly enabled diagnostic logging is unaffected. In MCP stdio mode, results are returned only through MCP and visible messages use `STDERR`. Interactively, commands render a friendly, human-readable view instead; the default `user` output format selects that friendly view and automatically falls back to JSON whenever output is redirected, piped, or run in machine mode. Bare piped stdin (`echo "..." | cosmosdbshell`) is not implicitly machine mode—pass `-c`, `--output json`, or `--quiet` to opt in. ## Theming @@ -359,6 +374,8 @@ Runtime commands for working with files: `theme save` writes a self-contained theme file with every color and style slot populated, so the saved file can be moved or edited without depending on another custom profile. +Successful `theme validate` runs preserve warnings on stderr in machine mode, including `--quiet`. Failed directory scans emit only the structured error on stderr. + ## How to Contribute We welcome contributions! See [CONTRIBUTING.md](./CONTRIBUTING.md) for ways to help, project architecture, and PR guidelines. diff --git a/docs/commands.md b/docs/commands.md index 749d5a70..788927d4 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -365,6 +365,8 @@ theme reload `theme validate` parses a TOML file and reports warnings without registering it or switching the active theme. When the argument is a directory it validates every `*.toml` file in that directory and prints a per-file summary. With no argument it scans the user themes directory (`~/.cosmosdbshell/themes`). The validator collects every issue in a single pass so that multiple typos can be fixed at once, and suggests the closest valid token when an unknown color or modifier is used. It also warns on bracket cycles that have only one color or contain duplicates. Pass `--strict` to fail when any warnings are present. Color values must be empty or one ANSI 16 color name. Style values may combine modifiers with at most one ANSI 16 color. +In machine mode (including `--quiet`), successful validation keeps warnings on stderr and the structured result on stdout. Directory scans defer warnings until every file has been checked; failed scans emit only the structured error on stderr. + ## Data Operations ### query @@ -770,6 +772,8 @@ Options: By default `watch` starts from now and follows the container, printing each change as highlighted JSON until you press Ctrl+C. Use `--from-beginning` to replay existing items first, `--partition-key` to scope the feed to one partition, and `--max` to stop automatically after a number of changes. Use `--interval` to change how long the shell waits between polls once it has caught up (default 1 second; values below 0.1 are clamped to avoid hammering the container). The change feed surfaces creates and updates (not deletes). This command is interactive and streaming, so it is not exposed over MCP. +Machine mode (`--quiet`, `--output json`, `--output csv`, or default execute-and-quit output) requires a positive `--max`. Changes are collected and emitted as the final result rather than streamed to stdout. An unbounded machine-mode watch fails before opening the change feed; interactive mode still supports following until Ctrl+C. + ```bash watch watch --from-beginning diff --git a/docs/connect.md b/docs/connect.md index 61c93b8b..eadcd0b2 100644 --- a/docs/connect.md +++ b/docs/connect.md @@ -22,6 +22,8 @@ The credential type is determined by the first matching rule (top-to-bottom): > **Note:** Step 3 is primarily a startup option (`--connect-vscode-credential`); it is also accepted as a hidden option on the interactive `connect` command. > > **Note:** `--azure-cli` takes precedence over the priority 6 interactive selection, so combining `--azure-cli` with `--tenant` uses `AzureCliCredential` (honoring the tenant) rather than triggering the browser/device-code flow. +> +> **Note:** In MCP stdio mode (`--mcp-stdio`) the shell never opens a browser. Priority 6 uses `DeviceCodeCredential` directly, and priority 8 uses `DefaultAzureCredential` without its interactive browser step. See [MCP stdio](mcp.md#stdio-headless-mode). The `--authority-host` option is honored by the Entra ID credentials that accept it: `VisualStudioCodeCredential` (priority 3), `ManagedIdentityCredential` (priority 5), the interactive `InteractiveBrowserCredential`/`DeviceCodeCredential` (priority 6), and `DefaultAzureCredential` (priority 8). It does not affect which credential type is selected, and the static token (priority 4) and account key paths ignore it. `AzureCliCredential` (priority 7) uses the cloud that the Azure CLI is already configured for, so it does not accept `--authority-host`; if you supply both, the value is ignored and a warning is printed. Use `az cloud set` to change the Azure CLI cloud instead. diff --git a/docs/mcp.md b/docs/mcp.md index a8960c81..37396893 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -11,6 +11,86 @@ dotnet run --project CosmosDBShell -- --mcp 5050 Bare `--mcp` starts the HTTP server on the default port `6128`. +### Stdio (Headless Mode) + +For an MCP client that launches and owns its server process, use: + +```bash +cosmosdbshell --mcp-stdio +``` + +This opens no HTTP listener and does not run an interactive shell. Each process +has its own connection and navigation state: + +- **stdin** accepts newline-delimited MCP JSON-RPC messages only, never shell scripts. +- **stdout** carries MCP messages only: responses, requests, and notifications. + Banners, command echoes, colors, and ordinary result rendering are disabled. +- **stderr** carries startup errors, logs, and any incidental command diagnostics. + Tool results and errors remain in MCP response envelopes. +- `--quiet` suppresses informational messages and progress, not warnings, errors, + required authentication instructions, or explicit diagnostic logging. +- Closing stdin stops the server and releases the shell's resources. +- Startup connection and navigation share a 60-second timeout. Closing stdin + cancels pending startup; a timeout is reported on stderr with exit code `4`. + Requests received during startup are buffered in a private delete-on-close + temporary file, capped at 8 MiB. Exceeding the cap cancels startup, reports an + input error on stderr, and exits with code `1`; normal transport after startup + is unaffected. Shutdown releases the file even if a stdin read remains blocked. +- Stdio mode does not load existing shell history or record commands in memory or + on disk. Existing history files are left untouched. Explicit `--diagnostics` + logging remains available through the normal secret-redaction pipeline. + +`--mcp-stdio` cannot be combined with `--mcp`, `--lsp`/`--stdio`, `-c`, `-k`, +or `--clear-history`, and must be passed directly rather than through a +response file (`@file`). Help and version output requested with `--mcp-stdio` goes +to stderr and exits without starting the server. + +Startup connection and navigation options are supported, for example +`--connect --connect-azure-cli --database --container `. +Prefer an existing Azure CLI login, managed identity, or another non-interactive +credential for unattended clients. + +Stdio mode never opens a browser: a headless server has no user at its terminal, +and the browser launcher could write to the protocol stdout. When `--tenant`/`--hint` +(or `--connect-tenant`/`--connect-hint` at startup) would start browser sign-in, the +shell uses device code sign-in instead; an endpoint-only connection uses +`DefaultAzureCredential` without its interactive browser step. Device code instructions +are written to stderr, even with `--quiet`, so a person must read them from the +client's server log. Tool sign-in waits until the code expires unless the client +cancels the tool call, which ends the attempt immediately. A startup `--connect` +that uses device code completes before the server answers MCP requests and is +subject to the shared 60-second startup timeout. For sign-in that may take longer, +start without `--connect` and invoke the `connect` tool after the MCP handshake. + +The `jq` and `theme` commands are not available in stdio mode: they are omitted +from the tool list and `help`, and calls to them fail as unknown tools. Both +launch external processes or manage terminal presentation, which has no meaning +for a headless server and could write to the protocol streams. Commands restricted +to non-MCP use (`exit`, `edit`, `watch`, `welcome`, `sproc`, `udf`, and `trigger`) +are also omitted, including their aliases. HTTP mode retains its existing tool +and help lists. Destructive commands (`delete`, `rm`, `rmdb`, and `rmcon`) remain +listed and callable through confirmation. They +use MCP confirmation/elicitation, never console input, and are refused when the +client cannot confirm. + +Launch an installed tool or a previously built executable, not `dotnet run` +with a build step: build output can contaminate stdout before the application +starts. + +Example VS Code `.vscode/mcp.json`: + +```json +{ + "servers": { + "localCosmosDBShellStdio": { + "type": "stdio", + "command": "cosmosdbshell", + "args": ["--mcp-stdio"] + } + } +} +``` + ## VS Code Setup > **Requires VS Code 1.103+** @@ -67,6 +147,11 @@ The MCP server runs locally with your user permissions. Connected clients can ex - Query and retrieve documents - Create, update, and delete resources +Stdio mode has no unauthenticated localhost listener; access is through the pipes +owned by the launching client. It is not a sandbox: that client still exercises +the process owner's filesystem and Cosmos permissions. Trust the client and its +agents just as you would for HTTP mode. + Server-side programming commands — stored procedures (`sproc`), user-defined functions (`udf`), and triggers (`trigger`) — are restricted from MCP. Run those commands manually in the shell. Transactional batches invoked through MCP must use the one-shot `batch run` subcommand. Stateful batch subcommands (`begin`, `add`, `execute`, `cancel`, `status`, and `show`, including their aliases) are restricted to the interactive shell because MCP tool calls share no client-specific batch state. @@ -85,7 +170,7 @@ This replaces any opt-in write flag: destructive commands are always allowed to Confirmation includes the connected account endpoint and current navigation location alongside the command and its explicit target arguments. If the connection or navigation state changes while confirmation is pending, the approved command is refused without executing; retry it to confirm the new context. Even navigating away and back invalidates the pending confirmation. A pending confirmation also expires when the MCP server restarts. -Shell and MCP command execution is serialized against the shared interpreter. Confirmation prompts do not hold the execution lock, so the shell remains usable while waiting. Clients still share a connection and navigation context: pass explicit `database` and `container` arguments for independent operations rather than relying on an earlier `cd` call. +Shell and MCP command execution is serialized against the shared interpreter. Confirmation prompts do not hold the execution lock, so the HTTP-mode shell remains usable while waiting. HTTP clients share a connection and navigation context; each stdio process has its own context. Pass explicit `database` and `container` arguments for independent operations rather than relying on an earlier `cd` call. The MCP confirmation applies even when a command is invoked with a force / no-prompt argument (for example `rmdb OldDB true`). That argument only skips the *interactive shell* prompt; it does not bypass the MCP elicitation gate. @@ -112,7 +197,7 @@ The HTTP server serves `2026-07-28` requests without a session and gives a sessi ### Data Exposure -MCP tool invocations are echoed as command lines in the shell window, so anyone watching the terminal can see what a connected client is doing. They are also recorded in the shell history. History entries are complete and replayable, including any supplied connection strings. Protect the history file accordingly. On Linux and macOS, the shell restricts the history file to its owner. +HTTP MCP tool invocations are echoed as command lines in the shell window and recorded in shell history. Stdio mode does neither and does not load existing history. HTTP history entries are complete and replayable, including any supplied connection strings. Protect the history file accordingly. On Linux and macOS, the shell restricts the history file to its owner. Positional arguments must be supplied without gaps: a call that provides a positional parameter while omitting an earlier one is rejected, because the equivalent shell command line would bind the value to the omitted slot. diff --git a/docs/navigation.md b/docs/navigation.md index f56dddde..12bdf94e 100644 --- a/docs/navigation.md +++ b/docs/navigation.md @@ -266,7 +266,7 @@ Start the shell with options to customize behavior: | Option | Description | | ------ | ----------- | | `--output ` | Output format for command results: `user` (default interactive view), `json`, `table`, or `csv`. Alias: `-o`. Selecting `json` or `csv` enables machine mode. Falls back to `COSMOSDB_SHELL_FORMAT`. | -| `--quiet` | Suppress standard informational output (banners, connection logs). Enables machine mode. Alias: `-q` | +| `--quiet` | Suppress informational messages, progress, banners, and command echoes. Preserve results, warnings, errors, and required authentication instructions. Enables machine mode. Alias: `-q` | | `-c ` | Execute command and exit. Everything after `-c` is taken as the command, so app-level options must come before `-c`. Windows-style `/c` is also accepted. | | `-k ` | Execute command and stay in shell. Everything after `-k` is taken as the command, so app-level options must come before `-k`. Windows-style `/k` is also accepted. | | `--connect ` | Connect with this connection string or endpoint on startup | @@ -280,6 +280,7 @@ Start the shell with options to customize behavior: | `--database ` | Navigate to this database after connecting at startup | | `--container ` | Navigate to this container after connecting at startup. Requires `--database` | | `--mcp [port]` | Enable MCP (Model Context Protocol) server on the given port, or `6128` by default | +| `--mcp-stdio` | Run a headless MCP server over stdin/stdout, with diagnostics on stderr and no listening port | | `--diagnostics [path]` | Write timestamped diagnostic logs (commands, timing, errors, connection events) to a file, or to a timestamped file in the config directory by default | | `--otel [endpoint]` | Enable distributed tracing so requests carry a sampled W3C `traceparent`. Optionally export spans to an OTLP `endpoint`; falls back to the `OTEL_EXPORTER_OTLP_ENDPOINT` environment variable | | `--color-system ` | Color scheme: 0=off, 1=standard, 2=truecolor (alias: `--cs`) | @@ -298,10 +299,17 @@ output. **Machine mode** is entered when any of the following is true: In machine mode the shell disables ANSI colors, suppresses connection/informational banners, emits command results as the selected structured format (JSON or CSV) on `STDOUT`, -and writes early parser/connection failures as a structured `{ "status": "error", "error": ... }` -object on `STDERR`. The human-facing `user` and `table` formats are not machine mode; `user` +and writes parser, connection, and command failures as a structured `{ "status": "error", "error": ... }` +object on `STDERR`. `STDOUT` carries only the command result; tables and other interactive +views are not rendered. The human-facing `user` and `table` formats are not machine mode; `user` falls back to JSON whenever output is redirected, piped, or run in machine mode. +Quiet is not a universal mute: warnings, errors, and required instructions (such as +authentication prompts and previews shown before a confirmation) remain visible on +`STDERR`, without ANSI styling. Explicit diagnostic +logging is unaffected. Command results are separate from informational messages and +remain on `STDOUT` (or in MCP responses for `--mcp-stdio`). + Bare piped stdin (for example `echo "..." | cosmosdbshell`) is **not** implicitly machine mode; pass `-c`, `--output json`, or `--quiet` to opt into structured output. @@ -352,6 +360,9 @@ cosmosdbshell --mcp # Start with MCP server enabled on a custom port cosmosdbshell --mcp 5050 +# Client-owned, headless MCP server; stdin/stdout carry only MCP messages +cosmosdbshell --mcp-stdio + # Capture a diagnostic log to the default location in the config directory cosmosdbshell --diagnostics @@ -364,3 +375,10 @@ cosmosdbshell --otel # Enable distributed tracing and export spans to an OTLP collector cosmosdbshell --otel http://localhost:4317 ``` + +`--mcp-stdio` is mutually exclusive with `--mcp`, `--lsp`/`--stdio`, `-c`, `-k`, +and `--clear-history`. It does not read stdin as a shell script or start a prompt. +Startup `--connect`, credential options, `--database`, and `--container` remain +available. Startup connection and navigation share a 60-second timeout and are +cancelled when stdin closes. A timeout is reported on stderr with exit code `4`. +See [MCP stdio mode](mcp.md#stdio-headless-mode). diff --git a/l10n/CosmosDBShell.json b/l10n/CosmosDBShell.json index 38a5fc5e..52157b5c 100644 --- a/l10n/CosmosDBShell.json +++ b/l10n/CosmosDBShell.json @@ -1119,6 +1119,7 @@ "command-version-example-1": "Display the current Cosmos Shell version", "command-version-mcp": "MCP running on port {0}", "command-version-mcp-off": "MCP server is off.", + "command-version-mcp-stdio": "MCP server: stdio", "command-version-repo": "Report issues at [link={0}]{1}[/]", "command-watch-description": "Tail the change feed of a container, streaming new and modified items as they arrive.", "command-watch-description-container": "The container to watch.", @@ -1126,9 +1127,10 @@ "command-watch-description-format": "{0}", "command-watch-description-from-beginning": "Start from the beginning of the change feed instead of from now.", "command-watch-description-interval": "Seconds to wait between change feed polls once caught up. Defaults to 1; values below 0.1 are clamped.", - "command-watch-description-max": "Stop after receiving this many changes. Omit, or use 0 or a negative value, to follow until Ctrl\u002BC.", + "command-watch-description-max": "Stop after receiving this many changes. Required and positive in machine mode. In interactive mode, omit or use 0 or a negative value to follow until Ctrl\u002BC.", "command-watch-description-partition-key": "Scope the change feed to a single partition key value.", "command-watch-error-invalid_interval": "Interval value \u0027{0}\u0027 is invalid. Specify a finite number of seconds.", + "command-watch-error-machine_max_required": "Machine-mode watch requires a positive --max value. Use interactive mode to follow changes without a limit.", "command-watch-error-request_failed": "Change feed request failed with status code {0} ({1}).", "command-watch-example-1": "Tail new changes in the current container as they arrive", "command-watch-example-2": "Replay the change feed from the beginning of the container", @@ -1306,12 +1308,13 @@ "help-HelpCommandScreenText": "Display this help screen.", "help-HelpOptionDescription": "Show this help text and exit.", "help-McpPort": "Enable MCP HTTP server. Optionally specify a port with --mcp \u003Cport\u003E; default is 6128.", + "help-McpStdio": "Run a headless MCP server over stdin/stdout, with diagnostics on stderr and no listening port.", "help-NotesHeadingText": "NOTES:", "help-OptionGroupWord": "Group", "help-OptionsHeadingText": "OPTIONS:", "help-Otel": "Enable distributed tracing so requests carry a sampled W3C traceparent. Optionally specify an OTLP endpoint with --otel \u003Cendpoint\u003E; falls back to the OTEL_EXPORTER_OTLP_ENDPOINT environment variable.", "help-OutputFormat": "The output format to use (user, json, table, csv).", - "help-Quiet": "Suppresses standard informational output.", + "help-Quiet": "Suppress informational messages, progress, banners, and command echoes. Preserve results, warnings, errors, and required authentication instructions.", "help-RequiredWord": "Required.", "help-SentenceMutuallyExclusiveSetErrors": "{0}", "help-SentenceMutuallyExclusiveSetErrors.__p0.one": "-\u003E Option \u0027{0}\u0027 is not compatible with: {1}", @@ -1404,6 +1407,12 @@ "mcp-error-creating-server": "Error creating MCP server: {0}", "mcp-error-invalid-port": "Error: --mcp port must be greater than 0.", "mcp-error-server-failed-start": "MCP server failed to start: {0}", + "mcp-error-stdio-console-input": "Interactive console input is unavailable in MCP stdio mode. Use MCP elicitation for confirmation.", + "mcp-error-stdio-incompatible-options": "Error: --mcp-stdio cannot be combined with --mcp, --lsp, --stdio, -c, -k, or --clear-history, and must be enabled.", + "mcp-error-stdio-input": "MCP stdio input failed: {0}", + "mcp-error-stdio-response-file": "Error: --mcp-stdio must be passed directly on the command line, not through a response file.", + "mcp-error-stdio-startup-buffer-limit": "Startup input exceeded the 8 MiB buffering limit. Wait for startup to finish before sending more requests.", + "mcp-error-stdio-startup-timeout": "MCP stdio startup connection and navigation timed out after 60 seconds. Use a non-interactive credential or connect through an MCP tool after starting the server.", "no_char": "N", "otel-error-invalid-endpoint": "Error: --otel endpoint \u0027{0}\u0027 is not a valid absolute URI.", "parser-error-prefix": "parse error", @@ -1430,6 +1439,7 @@ "shell-connect-default-auth": "Connecting with DefaultAzureCredential...", "shell-connect-devicecode-auth": "Browser authentication failed. Falling back to device code authentication.", "shell-connect-devicecode-fallback": "Browser authentication failed, falling back to device code authentication...", + "shell-connect-devicecode-stdio": "Browser sign-in is unavailable in MCP stdio mode. Using device code sign-in instead.", "shell-connect-error-cosmos-detail": "Cosmos DB request failed: HTTP {0} {1}, sub-status {2}, activity id {3}.", "shell-connect-key-auth": "Connecting with account key...", "shell-connect-key-env": "Using the account key from the COSMOSDB_SHELL_ACCOUNT_KEY environment variable.",