diff --git a/CosmosDBShell.Tests/Integration/ShellProcessTests.cs b/CosmosDBShell.Tests/Integration/ShellProcessTests.cs index c073ee48..50099740 100644 --- a/CosmosDBShell.Tests/Integration/ShellProcessTests.cs +++ b/CosmosDBShell.Tests/Integration/ShellProcessTests.cs @@ -6,6 +6,7 @@ namespace CosmosShell.Tests.Integration; using System.Diagnostics; using System.Text; +using System.Text.Json; using System.Text.RegularExpressions; using Azure.Data.Cosmos.Shell.Util; @@ -157,7 +158,7 @@ public async Task ConnectOption_WithExecuteAndQuit_RunsCommandAgainstEmulator() var result = await RunShellAsync( stdinScript: null, - extraArgs: ["--connect", connectionString, "-c", "connect"], + extraArgs: ["--connect", connectionString, "-c", "connect", "--output", "table"], cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(0, result.ExitCode); @@ -166,6 +167,81 @@ public async Task ConnectOption_WithExecuteAndQuit_RunsCommandAgainstEmulator() Assert.Contains("Current Location", result.StdOut, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task BadArguments_WithOutputJson_ReturnsExitCode2_AndJsonOnStdErr() + { + var result = await RunShellAsync( + stdinScript: null, + extraArgs: ["--output", "json", "-c", "not a real command"], + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(2, result.ExitCode); + Assert.Empty(result.StdOut.Trim()); + Assert.Contains("\"status\":\"error\"", result.StdErr, StringComparison.OrdinalIgnoreCase); + Assert.Contains("not recognized as an internal or external command", result.StdErr, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ConnectionFailure_WithOutputJson_ReturnsExitCode3_AndJsonOnStdErr() + { + var result = await RunShellAsync( + stdinScript: null, + extraArgs: ["--connect", "https://127.0.0.1:1", "--output", "json", "-c", "version"], + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(3, result.ExitCode); + Assert.Empty(result.StdOut.Trim()); + Assert.Contains("\"status\":\"error\"", result.StdErr, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAndQuit_WithoutOutput_EmitsParseableNdjsonOnStdOut() + { + var result = await RunShellAsync( + stdinScript: null, + extraArgs: ["-c", "version"], + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(0, result.ExitCode); + Assert.Empty(result.StdErr.Trim()); + + var lines = result.StdOut + .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + Assert.NotEmpty(lines); + + foreach (var line in lines) + { + using var doc = JsonDocument.Parse(line); + Assert.True(doc.RootElement.ValueKind == JsonValueKind.Object || doc.RootElement.ValueKind == JsonValueKind.Array); + } + } + + [Fact] + public async Task InvalidMcpPort_WithOutputJson_ReturnsExitCode2_AndJsonOnStdErr() + { + var result = await RunShellAsync( + stdinScript: null, + extraArgs: ["--output", "json", "--mcp", "0"], + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(2, result.ExitCode); + Assert.Empty(result.StdOut.Trim()); + Assert.Contains("\"status\":\"error\"", result.StdErr, StringComparison.OrdinalIgnoreCase); + Assert.Contains("port", result.StdErr, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ClearHistory_WithOutputJson_DoesNotWriteInformationalStdOut() + { + var result = await RunShellAsync( + stdinScript: null, + extraArgs: ["--output", "json", "--clear-history"], + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(0, result.ExitCode); + Assert.Empty(result.StdOut.Trim()); + } + private static async Task RunShellAsync( string stdinScript, CancellationToken cancellationToken) @@ -178,6 +254,11 @@ private static async Task RunShellAsync( IEnumerable? extraArgs, CancellationToken cancellationToken) { + var argsList = extraArgs?.ToList(); + var requiresOwnedStdin = stdinScript != null + || (argsList?.Contains("-c") == true) + || (argsList?.Contains("-k") == true); + var shellDll = Path.Combine(AppContext.BaseDirectory, "CosmosDBShell.dll"); if (!File.Exists(shellDll)) { @@ -191,7 +272,7 @@ private static async Task RunShellAsync( FileName = dotnet, WorkingDirectory = AppContext.BaseDirectory, UseShellExecute = false, - RedirectStandardInput = true, + RedirectStandardInput = requiresOwnedStdin, RedirectStandardOutput = true, RedirectStandardError = true, StandardOutputEncoding = Encoding.UTF8, @@ -200,9 +281,9 @@ private static async Task RunShellAsync( }; startInfo.ArgumentList.Add(shellDll); - if (extraArgs != null) + if (argsList != null) { - foreach (var a in extraArgs) + foreach (var a in argsList) { startInfo.ArgumentList.Add(a); } @@ -239,14 +320,14 @@ private static async Task RunShellAsync( process.BeginOutputReadLine(); process.BeginErrorReadLine(); - if (stdinScript != null) - { - await process.StandardInput.WriteAsync(stdinScript); - await process.StandardInput.WriteLineAsync(); - process.StandardInput.Close(); - } - else + if (requiresOwnedStdin) { + if (stdinScript != null) + { + await process.StandardInput.WriteAsync(stdinScript); + await process.StandardInput.WriteLineAsync(); + } + process.StandardInput.Close(); } diff --git a/CosmosDBShell.Tests/UtilTest/OutputFormatTests.cs b/CosmosDBShell.Tests/UtilTest/OutputFormatTests.cs index e2c80302..03c5d083 100644 --- a/CosmosDBShell.Tests/UtilTest/OutputFormatTests.cs +++ b/CosmosDBShell.Tests/UtilTest/OutputFormatTests.cs @@ -31,6 +31,22 @@ void TestJSon() Assert.Equal(StripWS(input), StripWS(output)); } + [Fact] + void TestNdjson() + { + var input = "[{ \"id\": 12 }, { \"id\": 13 }]"; + var element = JsonSerializer.Deserialize(input); + + var commandState = new CommandState(); + commandState.Result = new ShellJson(element); + commandState.OutputFormat = OutputFormat.Ndjson; + + var output = commandState.GenerateOutputText(); + var expected = "{\"id\":12}" + Environment.NewLine + "{\"id\":13}" + Environment.NewLine; + + Assert.Equal(expected, output); + } + [Fact] void TestCSV() { diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConnectCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConnectCommand.cs index e23dbef5..b60192d9 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConnectCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConnectCommand.cs @@ -55,10 +55,12 @@ internal partial class ConnectCommand : CosmosCommand public async override Task ExecuteAsync(ShellInterpreter shell, CommandState commandState, string commandText, CancellationToken token) { + var isQuiet = shell.Options?.Quiet == true; + // If no connection string provided, show current connection info if (this.ConnectionString is null) { - return await PrintConnectionInfoAsync(shell, commandState, token); + return await PrintConnectionInfoAsync(shell, commandState, token, isQuiet); } // If already connected, inform user about switching accounts. @@ -67,7 +69,10 @@ public async override Task ExecuteAsync(ShellInterpreter shell, Co if (shell.State is ConnectedState cs) { var previousEndpoint = cs.Client.Endpoint.Host; - AnsiConsole.MarkupLine(MessageService.GetArgsString("command-connect-switching", "endpoint", previousEndpoint)); + if (!isQuiet) + { + AnsiConsole.MarkupLine(MessageService.GetArgsString("command-connect-switching", "endpoint", previousEndpoint)); + } } ConnectionMode? connectionMode = null; @@ -95,7 +100,7 @@ public async override Task ExecuteAsync(ShellInterpreter shell, Co await shell.ConnectAsync(this.ConnectionString, this.LoginHint, connectionMode, tenantId: this.TenantId, authorityHost: this.AuthorityHost, managedIdentityClientId: this.ManagedIdentityClientId, useVSCodeCredential: this.UseVSCodeCredential, subscriptionId: this.SubscriptionId, resourceGroupName: this.ResourceGroupName, token: token); var returnState = new CommandState { - IsPrinted = true, + IsPrinted = !isQuiet, }; var endpoint = ParsedDocDBConnectionString.ExtractEndpoint(this.ConnectionString); var resultElement = JsonSerializer.SerializeToElement(new Dictionary @@ -111,7 +116,7 @@ public async override Task ExecuteAsync(ShellInterpreter shell, Co } catch (Exception e) { - if (TryGetPrincipalIdFromRbacException(e, out var id, out var permission)) + if (!isQuiet && TryGetPrincipalIdFromRbacException(e, out var id, out var permission)) { AskForRBacPermissions(id ?? string.Empty, permission ?? string.Empty); return commandState; @@ -183,13 +188,16 @@ internal static void PrintConnectUsageHint(ShellInterpreter shell) AnsiConsole.MarkupLine(Markup.Escape(MessageService.GetString("command-connect-not_connected-usage-footer"))); } - private static async Task PrintConnectionInfoAsync(ShellInterpreter shell, CommandState commandState, CancellationToken token) + private static async Task PrintConnectionInfoAsync(ShellInterpreter shell, CommandState commandState, CancellationToken token, bool isQuiet) { if (shell.State is not ConnectedState connectedState) { - AnsiConsole.MarkupLine(MessageService.GetString("command-connect-not_connected")); - PrintConnectUsageHint(shell); - commandState.IsPrinted = true; + if (!isQuiet) + { + AnsiConsole.MarkupLine(MessageService.GetString("command-connect-not_connected")); + PrintConnectUsageHint(shell); + } + commandState.IsPrinted = !isQuiet; var notConnectedJson = new Dictionary { ["connected"] = false, @@ -202,36 +210,39 @@ private static async Task PrintConnectionInfoAsync(ShellInterprete token.ThrowIfCancellationRequested(); var acc = await client.ReadAccountAsync().WaitAsync(token); - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-connect-info-title"))); + var connectionMode = client.ClientOptions.ConnectionMode; + string currentLocation = ShellLocation.GetCurrentLocation(shell.State) ?? ShellLocation.NotConnectedText; - var table = new Table(); - table.AddColumns(string.Empty, string.Empty); - table.HideHeaders(); + if (!isQuiet) + { + AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-connect-info-title"))); - table.AddRow(MessageService.GetString("command-connect-info-account"), Theme.FormatTableValue(acc.Id)); - table.AddRow(MessageService.GetString("command-connect-info-endpoint"), Theme.FormatTableValue(client.Endpoint.ToString())); + var table = new Table(); + table.AddColumns(string.Empty, string.Empty); + table.HideHeaders(); - if (connectedState.ArmContext != null) - { - table.AddRow(MessageService.GetString("command-connect-info-arm-account"), Theme.FormatTableValue(connectedState.ArmContext.AccountResourceId.ToString())); - } + table.AddRow(MessageService.GetString("command-connect-info-account"), Theme.FormatTableValue(acc.Id)); + table.AddRow(MessageService.GetString("command-connect-info-endpoint"), Theme.FormatTableValue(client.Endpoint.ToString())); - // Display the connection mode - var connectionMode = client.ClientOptions.ConnectionMode; - table.AddRow(MessageService.GetString("command-connect-info-mode"), Theme.FormatTableValue(connectionMode.ToString())); + if (connectedState.ArmContext != null) + { + table.AddRow(MessageService.GetString("command-connect-info-arm-account"), Theme.FormatTableValue(connectedState.ArmContext.AccountResourceId.ToString())); + } - // Display the readable/writable regions - table.AddRow(MessageService.GetString("command-connect-info-read-regions"), Theme.FormatTableValue(string.Join(", ", acc.ReadableRegions.Select(r => r.Name)))); - table.AddRow(MessageService.GetString("command-connect-info-write-regions"), Theme.FormatTableValue(string.Join(", ", acc.WritableRegions.Select(r => r.Name)))); + // Display the connection mode + table.AddRow(MessageService.GetString("command-connect-info-mode"), Theme.FormatTableValue(connectionMode.ToString())); - // Show current navigation state - string currentLocation = ShellLocation.GetCurrentLocation(shell.State) ?? ShellLocation.NotConnectedText; + // Display the readable/writable regions + table.AddRow(MessageService.GetString("command-connect-info-read-regions"), Theme.FormatTableValue(string.Join(", ", acc.ReadableRegions.Select(r => r.Name)))); + table.AddRow(MessageService.GetString("command-connect-info-write-regions"), Theme.FormatTableValue(string.Join(", ", acc.WritableRegions.Select(r => r.Name)))); - table.AddRow(MessageService.GetString("command-connect-info-location"), Theme.ConnectedStatePromt(currentLocation)); + // Show current navigation state + table.AddRow(MessageService.GetString("command-connect-info-location"), Theme.ConnectedStatePromt(currentLocation)); - AnsiConsole.Write(table); + AnsiConsole.Write(table); + } - commandState.IsPrinted = true; + commandState.IsPrinted = !isQuiet; var jsonResult = new Dictionary { ["connected"] = true, diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CommandState.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CommandState.cs index 98720118..802d3f29 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CommandState.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CommandState.cs @@ -19,6 +19,11 @@ public partial class CommandState /// public virtual bool IsError => false; + /// + /// Gets the exit code for the command state. Default is 0. + /// + public virtual int ExitCode => 0; + /// /// Gets or sets the output format for the command result. /// @@ -55,9 +60,13 @@ internal void SetFormat(string? outputFormat) { this.OutputFormat = OutputFormat.Table; } + else if (string.Equals(outputFormat, "ndjson", StringComparison.OrdinalIgnoreCase)) + { + this.OutputFormat = OutputFormat.Ndjson; + } else { - throw new ShellException(MessageService.GetString("error-invalid_output_format", new Dictionary { { "format", outputFormat } })); + throw new ArgumentException(MessageService.GetString("error-invalid_output_format", new Dictionary { { "format", outputFormat } })); } } @@ -123,6 +132,39 @@ internal string GenerateOutputText() } return ResultToTable(json.EnumerateArray().ToArray()).ToGridString(); + case OutputFormat.Ndjson: + { + var array = json; + if (json.ValueKind == JsonValueKind.Object) + { + if (json.TryGetProperty("documents", out var documents)) + { + array = documents; + } + else if (json.TryGetProperty("items", out var items)) + { + array = items; + } + else + { + return JsonSerializer.Serialize(json) + Environment.NewLine; + } + } + + if (array.ValueKind == JsonValueKind.Array) + { + var sb = new StringBuilder(); + foreach (var element in array.EnumerateArray()) + { + sb.AppendLine(JsonSerializer.Serialize(element)); + } + + return sb.ToString(); + } + + return JsonSerializer.Serialize(array) + Environment.NewLine; + } + default: throw new InvalidOperationException("OutputFormat invalid " + this.OutputFormat); } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ErrorCommandState.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ErrorCommandState.cs index f4de5468..9f040a7a 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ErrorCommandState.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ErrorCommandState.cs @@ -15,4 +15,64 @@ internal class ErrorCommandState(Exception exception) : CommandState public Exception Exception { get; init; } = exception; public override bool IsError => true; + + public override int ExitCode + { + get + { + var ex = this.Exception; + while (true) + { + if (ex is CommandException ce && ce.InnerException is not null) + { + ex = ce.InnerException; + continue; + } + + if (ex is ShellException se && se.InnerException is not null) + { + ex = se.InnerException; + continue; + } + + break; + } + + if (ex is Azure.Identity.AuthenticationFailedException + || ex is Azure.Identity.CredentialUnavailableException + || ex is System.Net.Http.HttpRequestException + || ex is System.Net.Sockets.SocketException + || ex is TimeoutException) + { + return 3; + } + + if (ex is Microsoft.Azure.Cosmos.CosmosException cosmosEx) + { + return cosmosEx.StatusCode switch + { + System.Net.HttpStatusCode.Unauthorized => 3, + System.Net.HttpStatusCode.Forbidden => 3, + System.Net.HttpStatusCode.NotFound => 4, + System.Net.HttpStatusCode.TooManyRequests => 5, + System.Net.HttpStatusCode.RequestTimeout => 3, + System.Net.HttpStatusCode.ServiceUnavailable => 3, + System.Net.HttpStatusCode.GatewayTimeout => 3, + _ => 1, + }; + } + + if (ex is Azure.Data.Cosmos.Shell.Parser.CommandNotFoundException) + { + return 2; + } + + if (ex is ArgumentException) + { + return 2; + } + + return 1; + } + } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/OutputFormat.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/OutputFormat.cs index 6bb6c380..e9b9d1c7 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/OutputFormat.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/OutputFormat.cs @@ -23,4 +23,9 @@ public enum OutputFormat /// Output as an aligned text table. /// Table, + + /// + /// Output in NDJSON (Newline Delimited JSON) format. + /// + Ndjson, } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ParserErrorCommandState.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ParserErrorCommandState.cs index 399269a4..5057d84c 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ParserErrorCommandState.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ParserErrorCommandState.cs @@ -13,7 +13,9 @@ public ParserErrorCommandState(ErrorList errors) this.Errors = errors; } - public ErrorList Errors { get; } + public ErrorList Errors { get; init; } public override bool IsError => true; + + public override int ExitCode => 2; } \ No newline at end of file diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs index 2770f71d..6f8b601f 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs @@ -185,6 +185,11 @@ public static ShellInterpreter CreateInstance() /// 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); } @@ -194,6 +199,11 @@ public static void WriteLine(string message, params object[] par) /// The message to write. public static void WriteLine(string message) { + if (Instance?.Options?.Quiet == true) + { + return; + } + Console.WriteLine(message); } @@ -202,6 +212,11 @@ public static void WriteLine(string message) /// public static void WriteLine() { + if (Instance?.Options?.Quiet == true) + { + return; + } + Console.WriteLine(); } @@ -212,6 +227,11 @@ public static void WriteLine() /// 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); } @@ -221,6 +241,11 @@ public static void Write(string message, params object[] par) /// The message to write. public static void Write(string message) { + if (Instance?.Options?.Quiet == true) + { + return; + } + Console.Write(message); } @@ -563,26 +588,31 @@ internal ShellObject GetVariable(string name) internal void PrintVersion(CommandState? commandState) { + var isQuiet = this.Options?.Quiet == true; var version = GetDisplayVersion(typeof(VersionCommand).Assembly); - var versionString = MessageService.GetArgsString("command-version", "version", version); - AnsiConsole.MarkupLine(versionString); - var port = this.McpPort; - if (port != null) - { - var mcpPortString = MessageService.GetArgsString("command-version-mcp", "mcp_port", port?.ToString() ?? string.Empty); - AnsiConsole.MarkupLine(Theme.FormatWarning(mcpPortString)); - } - else - { - AnsiConsole.MarkupLine(MessageService.GetString("command-version-mcp-off")); - } - var repoUrl = GetRepositoryUrl(typeof(VersionCommand).Assembly); - if (!string.IsNullOrEmpty(repoUrl)) + + if (!isQuiet) { - var repoString = MessageService.GetArgsString("command-version-repo", "url", repoUrl); - AnsiConsole.MarkupLine(repoString); + var versionString = MessageService.GetArgsString("command-version", "version", version); + AnsiConsole.MarkupLine(versionString); + + if (port != null) + { + var mcpPortString = MessageService.GetArgsString("command-version-mcp", "mcp_port", port?.ToString() ?? string.Empty); + AnsiConsole.MarkupLine(Theme.FormatWarning(mcpPortString)); + } + else + { + AnsiConsole.MarkupLine(MessageService.GetString("command-version-mcp-off")); + } + + if (!string.IsNullOrEmpty(repoUrl)) + { + var repoString = MessageService.GetArgsString("command-version-repo", "url", repoUrl); + AnsiConsole.MarkupLine(repoString); + } } if (commandState != null) @@ -598,7 +628,7 @@ internal void PrintVersion(CommandState? commandState) var jsonElement = System.Text.Json.JsonSerializer.SerializeToElement(json); commandState.Result = new ShellJson(jsonElement); - commandState.IsPrinted = true; + commandState.IsPrinted = !isQuiet; } } @@ -1321,6 +1351,9 @@ internal CommandState PrintState(CommandState state) try { string? output; + var inMachineMode = this.Options?.Quiet == true + || string.Equals(this.Options?.Output, "json", StringComparison.OrdinalIgnoreCase) + || string.Equals(this.Options?.Output, "ndjson", StringComparison.OrdinalIgnoreCase); if (state.Result?.DataType == Parser.DataType.Json) { @@ -1328,7 +1361,9 @@ internal CommandState PrintState(CommandState state) // syntax highlighting using the configured Spectre.Console theme. File // redirection still receives plain text so downstream tooling and tests // are unaffected. - if (state.OutputFormat == OutputFormat.JSon && string.IsNullOrEmpty(this.StdOutRedirect)) + if (!inMachineMode + && state.OutputFormat == OutputFormat.JSon + && string.IsNullOrEmpty(this.StdOutRedirect)) { var element = (JsonElement?)state.Result.ConvertShellObject(Parser.DataType.Json); if (element.HasValue) @@ -1348,7 +1383,9 @@ internal CommandState PrintState(CommandState state) // When a text result carries a highlighter (e.g. script bodies), apply it // when writing to the terminal. Redirection and piping still receive plain // text so downstream tooling and tests are unaffected. - if (output != null && string.IsNullOrEmpty(this.StdOutRedirect) + if (!inMachineMode + && output != null + && string.IsNullOrEmpty(this.StdOutRedirect) && state.Result is ShellText { Highlighter: { } highlighter }) { AnsiConsole.MarkupLine(highlighter(output)); @@ -1361,7 +1398,14 @@ internal CommandState PrintState(CommandState state) { if (string.IsNullOrEmpty(this.StdOutRedirect)) { - WriteLine(output); + if (output.EndsWith(Environment.NewLine)) + { + Console.Out.Write(output); + } + else + { + Console.Out.WriteLine(output); + } } else { @@ -1384,10 +1428,24 @@ internal CommandState PrintState(CommandState state) // exceptions) carry an actionable message; the stack trace is noise // for end users. Show only Message chains and let --verbose surface // the full exception. + var inMachineMode = this.Options?.Quiet == true + || string.Equals(this.Options?.Output, "json", StringComparison.OrdinalIgnoreCase) + || string.Equals(this.Options?.Output, "ndjson", StringComparison.OrdinalIgnoreCase); + if (e is OperationCanceledException) { var canceled = MessageService.GetString("runtime-error-canceled"); - if (!string.IsNullOrEmpty(canceled)) + + if (inMachineMode) + { + var errObj = new + { + status = "error", + error = string.IsNullOrEmpty(canceled) ? e.Message : canceled, + }; + Console.Error.WriteLine(JsonSerializer.Serialize(errObj)); + } + else if (!string.IsNullOrEmpty(canceled)) { AnsiConsole.MarkupLine(Theme.FormatWarning(canceled)); } @@ -1395,18 +1453,30 @@ internal CommandState PrintState(CommandState state) return new ErrorCommandState(e); } - var prefix = MessageService.GetString("runtime-error-prefix") ?? "error"; - AnsiConsole.MarkupLine($"{Theme.FormatError(prefix + ":")} {Markup.Escape(e.Message)}"); - if (e is IShellExceptionWithHint hinted && !string.IsNullOrEmpty(hinted.Hint)) + if (inMachineMode) { - AnsiConsole.MarkupLine(Markup.Escape(hinted.Hint)); + var errObj = new + { + status = "error", + error = e.Message, + }; + Console.Error.WriteLine(JsonSerializer.Serialize(errObj)); } - - var inner = e.InnerException; - while (inner != null) + else { - AnsiConsole.MarkupLine($" {Theme.FormatError("\u2192")} {Markup.Escape(inner.Message)}"); - inner = inner.InnerException; + var prefix = MessageService.GetString("runtime-error-prefix") ?? "error"; + AnsiConsole.MarkupLine($"{Theme.FormatError(prefix + ":")} {Markup.Escape(e.Message)}"); + if (e is IShellExceptionWithHint hinted && !string.IsNullOrEmpty(hinted.Hint)) + { + AnsiConsole.MarkupLine(Markup.Escape(hinted.Hint)); + } + + var inner = e.InnerException; + while (inner != null) + { + AnsiConsole.MarkupLine($" {Theme.FormatError("\u2192")} {Markup.Escape(inner.Message)}"); + inner = inner.InnerException; + } } return new ErrorCommandState(e); @@ -1835,6 +1905,17 @@ private void ReportExecutionError(Exception e, string? sourceText = null) return; } + if (this.Options?.Quiet == true || string.Equals(this.Options?.Output, "json", StringComparison.OrdinalIgnoreCase) || string.Equals(this.Options?.Output, "ndjson", StringComparison.OrdinalIgnoreCase)) + { + var errObj = new + { + status = "error", + error = e.Message, + }; + Console.Error.WriteLine(JsonSerializer.Serialize(errObj)); + return; + } + if (e is PositionalException pe) { this.ReportPositionalError(pe); @@ -2059,6 +2140,31 @@ private string[] SplitIntoLines(string text) private void ReportParserErrors(ErrorList errors, string commandText) { + if ((this.Options?.Quiet == true + || string.Equals(this.Options?.Output, "json", StringComparison.OrdinalIgnoreCase) + || string.Equals(this.Options?.Output, "ndjson", StringComparison.OrdinalIgnoreCase)) + && errors != null && errors.Count > 0) + { + var errorStrings = new System.Collections.Generic.List(); + foreach (var err in errors) + { + if (err != null && err.ErrorLevel == ErrorLevel.Error) + { + errorStrings.Add(err.Message ?? "Parser error"); + } + } + + if (errorStrings.Count > 0) + { + var errObj = new + { + status = "error", + error = string.Join("; ", errorStrings), + }; + Console.Error.WriteLine(JsonSerializer.Serialize(errObj)); + return; + } + } if (errors == null || errors.Count == 0) { return; diff --git a/CosmosDBShell/Program.cs b/CosmosDBShell/Program.cs index e4e8a833..829acfce 100644 --- a/CosmosDBShell/Program.cs +++ b/CosmosDBShell/Program.cs @@ -69,18 +69,42 @@ public static async Task Main(string[] args) if (parseResult.Errors.Count > 0) { - foreach (var error in parseResult.Errors) + var outputMode = parseResult.GetValueForOption(optionMap.Output); + var quiet = parseResult.GetValueForOption(optionMap.Quiet); + var isNonInteractive = args.Contains("-c", StringComparer.Ordinal) || Console.IsInputRedirected; + + var isMachineMode = quiet + || string.Equals(outputMode, "json", StringComparison.OrdinalIgnoreCase) + || string.Equals(outputMode, "ndjson", StringComparison.OrdinalIgnoreCase) + || (isNonInteractive && string.IsNullOrWhiteSpace(outputMode)); + if (isMachineMode) { - ShellInterpreter.WriteLine(error.Message); + var errorStrings = parseResult.Errors.Select(e => e.Message).ToList(); + var errObj = new + { + status = "error", + error = string.Join("; ", errorStrings) + }; + Console.Error.WriteLine(System.Text.Json.JsonSerializer.Serialize(errObj)); } + else + { + foreach (var error in parseResult.Errors) + { + ShellInterpreter.WriteLine(error.Message); + } - ShellInterpreter.WriteLine(BuildHelpText()); - Environment.ExitCode = 1; + ShellInterpreter.WriteLine(BuildHelpText()); + } + + Environment.ExitCode = 2; return; } var o = new CosmosShellOptions { + Output = parseResult.GetValueForOption(optionMap.Output), + Quiet = parseResult.GetValueForOption(optionMap.Quiet), ColorSystem = parseResult.GetValueForOption(optionMap.ColorSystem), ExecuteAndQuit = parseResult.GetValueForOption(optionMap.ExecuteAndQuit), ExecuteAndContinue = parseResult.GetValueForOption(optionMap.ExecuteAndContinue), @@ -137,9 +161,23 @@ public static async Task Main(string[] args) if (!string.IsNullOrWhiteSpace(o.OtlpEndpoint) && !Uri.TryCreate(o.OtlpEndpoint, UriKind.Absolute, out _)) { - Environment.ExitCode = 1; - ShellInterpreter.WriteLine(MessageService.GetArgsString( - "otel-error-invalid-endpoint", "endpoint", o.OtlpEndpoint)); + Environment.ExitCode = 2; + var msg = MessageService.GetArgsString( + "otel-error-invalid-endpoint", "endpoint", o.OtlpEndpoint); + var inMachineMode = o.Quiet + || string.Equals(o.Output, "json", StringComparison.OrdinalIgnoreCase) + || string.Equals(o.Output, "ndjson", StringComparison.OrdinalIgnoreCase) + || ((args.Contains("-c", StringComparer.Ordinal) || Console.IsInputRedirected) + && string.IsNullOrWhiteSpace(o.Output)); + + if (inMachineMode) + { + Console.Error.WriteLine(System.Text.Json.JsonSerializer.Serialize(new { status = "error", error = msg })); + } + else + { + ShellInterpreter.WriteLine(msg); + } return; } } @@ -154,8 +192,26 @@ public static async Task Main(string[] args) if (!string.IsNullOrWhiteSpace(o.ExecuteAndQuit) && !string.IsNullOrWhiteSpace(o.ExecuteAndContinue)) { - Environment.ExitCode = 1; - ShellInterpreter.WriteLine(MessageService.GetString("error-mutually-exclusive-options")); + Environment.ExitCode = 2; + var inMachineMode = o.Quiet + || string.Equals(o.Output, "json", StringComparison.OrdinalIgnoreCase) + || string.Equals(o.Output, "ndjson", StringComparison.OrdinalIgnoreCase) + || ((args.Contains("-c", StringComparer.Ordinal) || Console.IsInputRedirected) + && string.IsNullOrWhiteSpace(o.Output)); + + if (inMachineMode) + { + var errObj = new + { + status = "error", + error = MessageService.GetString("error-mutually-exclusive-options") + }; + Console.Error.WriteLine(System.Text.Json.JsonSerializer.Serialize(errObj)); + } + else + { + ShellInterpreter.WriteLine(MessageService.GetString("error-mutually-exclusive-options")); + } return; } @@ -163,6 +219,37 @@ public static async Task Main(string[] args) var executeAndContinueCommand = string.IsNullOrWhiteSpace(o.ExecuteAndContinue) ? null : o.ExecuteAndContinue; var explicitCommand = executeAndContinueCommand ?? executeAndQuitCommand; + if (string.IsNullOrWhiteSpace(o.Output) && !string.IsNullOrWhiteSpace(executeAndQuitCommand)) + { + o.Output = "ndjson"; + } + + if (!string.IsNullOrWhiteSpace(o.Output)) + { + Environment.SetEnvironmentVariable("COSMOSDB_SHELL_FORMAT", o.Output); + } + + var structuredOutputMode = + string.Equals(o.Output, "json", StringComparison.OrdinalIgnoreCase) + || string.Equals(o.Output, "ndjson", StringComparison.OrdinalIgnoreCase); + var startupMachineMode = o.Quiet || structuredOutputMode; + + void WriteStartupError(string message) + { + if (startupMachineMode) + { + var errObj = new + { + status = "error", + error = message, + }; + Console.Error.WriteLine(System.Text.Json.JsonSerializer.Serialize(errObj)); + return; + } + + AnsiConsole.WriteLine(message); + } + if (o.ClearHistory) { if (File.Exists(ShellInterpreter.Instance.HistoryFile)) @@ -170,18 +257,40 @@ public static async Task Main(string[] args) File.Delete(ShellInterpreter.Instance.HistoryFile); } - ShellInterpreter.WriteLine(MessageService.GetString("shell-hisory_file_deleted")); + if (!startupMachineMode) + { + ShellInterpreter.WriteLine(MessageService.GetString("shell-hisory_file_deleted")); + } return; } - AnsiConsole.Profile.Capabilities.ColorSystem = o.ColorSystem switch + var colorSystemVal = o.ColorSystem; + if (startupMachineMode) + { + colorSystemVal = 0; // Force NoColors in machine mode + o.Quiet = true; // Suppress informational messages to keep output clean + } + + AnsiConsole.Profile.Capabilities.ColorSystem = colorSystemVal switch { 1 => ColorSystem.Standard, 2 => ColorSystem.TrueColor, _ => ColorSystem.NoColors, }; - ApplyTheme(o.Theme); + ApplyTheme(o.Theme, startupMachineMode); + + if (startupMachineMode) + { + // Keep machine-mode stdout deterministic even if a command + // accidentally writes via AnsiConsole instead of command state output. + AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(TextWriter.Null), + }); + } ShellInterpreter.Instance.Options = o; @@ -223,14 +332,36 @@ await ShellInterpreter.Instance.ConnectAsync( } catch (Exception ex) { - Environment.ExitCode = 1; - if (ConnectCommand.TryGetPrincipalIdFromRbacException(ex, out var id, out var permission)) + var errorState = new ErrorCommandState(ex); + Environment.ExitCode = errorState.ExitCode; + + var inMachineMode = o.Quiet + || string.Equals(o.Output, "json", StringComparison.OrdinalIgnoreCase) + || string.Equals(o.Output, "ndjson", StringComparison.OrdinalIgnoreCase) + || ((args.Contains("-c", StringComparer.Ordinal) || Console.IsInputRedirected) + && string.IsNullOrWhiteSpace(o.Output)); + + if (!inMachineMode + && ConnectCommand.TryGetPrincipalIdFromRbacException(ex, out var id, out var permission)) { ConnectCommand.AskForRBacPermissions(id ?? string.Empty, permission ?? string.Empty); return; } - ShellInterpreter.WriteLine(ex.Message); + if (inMachineMode) + { + var errObj = new + { + status = "error", + error = ex.Message, + }; + Console.Error.WriteLine(System.Text.Json.JsonSerializer.Serialize(errObj)); + } + else + { + ShellInterpreter.WriteLine(ex.Message); + } + return; } } @@ -242,8 +373,8 @@ await ShellInterpreter.Instance.ConnectAsync( { if (mcpPort <= 0) { - AnsiConsole.WriteLine(MessageService.GetString("mcp-error-invalid-port")); - Environment.ExitCode = 1; + WriteStartupError(MessageService.GetString("mcp-error-invalid-port")); + Environment.ExitCode = 2; return; } @@ -253,7 +384,7 @@ await ShellInterpreter.Instance.ConnectAsync( } catch (Exception ex) { - AnsiConsole.WriteLine(MessageService.GetArgsString("mcp-error-creating-server", "message", ex.Message)); + WriteStartupError(MessageService.GetArgsString("mcp-error-creating-server", "message", ex.Message)); Environment.ExitCode = 1; return; } @@ -271,7 +402,7 @@ await ShellInterpreter.Instance.ConnectAsync( } catch (Exception ex) { - AnsiConsole.WriteLine(MessageService.GetArgsString("mcp-error-server-failed-start", "message", ex.Message)); + WriteStartupError(MessageService.GetArgsString("mcp-error-server-failed-start", "message", ex.Message)); Environment.ExitCode = 1; } }); @@ -287,7 +418,7 @@ await ShellInterpreter.Instance.ConnectAsync( var state = await ShellInterpreter.Instance.ExecuteCommandAsync(script, default); if (state.IsError) { - Environment.ExitCode = 1; + Environment.ExitCode = state.ExitCode; if (executeAndContinueCommand is null) { await StopHostAsync(host, hostTask); @@ -311,7 +442,7 @@ await ShellInterpreter.Instance.ConnectAsync( var state = await ShellInterpreter.Instance.ExecuteCommandAsync(explicitCommand, default); if (state.IsError) { - Environment.ExitCode = 1; + Environment.ExitCode = state.ExitCode; if (executeAndContinueCommand is null) { await StopHostAsync(host, hostTask); @@ -447,6 +578,43 @@ private static (RootCommand Command, OptionMap Map) BuildRootCommand() getDefaultValue: () => 2, description: MessageService.GetString("help-ColorSystem")); + var output = new Option( + aliases: ["--output", "-o"], + parseArgument: argResult => + { + if (argResult.Tokens.Count == 0) + { + return null; + } + + var token = argResult.Tokens[0].Value; + if (string.Equals(token, "js", StringComparison.OrdinalIgnoreCase)) + { + token = "json"; + } + else if (string.Equals(token, "tbl", StringComparison.OrdinalIgnoreCase)) + { + token = "table"; + } + + if (string.Equals(token, "json", StringComparison.OrdinalIgnoreCase) + || string.Equals(token, "ndjson", StringComparison.OrdinalIgnoreCase) + || string.Equals(token, "table", StringComparison.OrdinalIgnoreCase) + || string.Equals(token, "csv", StringComparison.OrdinalIgnoreCase)) + { + return token.ToLowerInvariant(); + } + + argResult.ErrorMessage = MessageService.GetString( + "error-invalid_output_format", + new Dictionary { { "format", token } }); + return null; + }, + description: MessageService.GetString("help-OutputFormat")); + var quiet = new Option( + aliases: ["--quiet", "-q"], + description: MessageService.GetString("help-Quiet")); + var executeAndQuit = new Option("-c", MessageService.GetString("help-ExecuteAndQuit")); var executeAndContinue = new Option("-k", MessageService.GetString("help-ExecuteAndContinue")); @@ -513,6 +681,8 @@ private static (RootCommand Command, OptionMap Map) BuildRootCommand() var root = new RootCommand("Cosmos DB Shell") { + output, + quiet, colorSystem, executeAndQuit, executeAndContinue, @@ -536,6 +706,8 @@ private static (RootCommand Command, OptionMap Map) BuildRootCommand() }; var map = new OptionMap( + output, + quiet, colorSystem, executeAndQuit, executeAndContinue, @@ -633,7 +805,7 @@ private static string BuildHelpText() /// environment variable, then the built-in default. Unknown names emit a warning /// to standard output and fall back to the default profile. /// - private static void ApplyTheme(string? themeFromCli) + private static void ApplyTheme(string? themeFromCli, bool suppressStartupWarnings) { // 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` @@ -643,7 +815,10 @@ private static void ApplyTheme(string? themeFromCli) registry.LoadFromDirectory(ThemeFile.DefaultUserThemesDirectory()); foreach (var warning in registry.Warnings) { - ShellInterpreter.WriteLine(warning); + if (!suppressStartupWarnings) + { + ShellInterpreter.WriteLine(warning); + } } var requested = !string.IsNullOrWhiteSpace(themeFromCli) @@ -657,18 +832,23 @@ private static void ApplyTheme(string? themeFromCli) if (!ThemeProfiles.TryGet(requested, out var profile)) { - ShellInterpreter.WriteLine(MessageService.GetArgsString( - "warning-unknown-theme", - "name", - requested, - "themes", - string.Join(", ", registry.All.Keys))); + if (!suppressStartupWarnings) + { + ShellInterpreter.WriteLine(MessageService.GetArgsString( + "warning-unknown-theme", + "name", + requested, + "themes", + string.Join(", ", registry.All.Keys))); + } } Theme.Apply(profile); } private sealed record OptionMap( + Option Output, + Option Quiet, Option ColorSystem, Option ExecuteAndQuit, Option ExecuteAndContinue, @@ -731,6 +911,10 @@ private static string GetDisplayName(System.CommandLine.Parsing.SymbolResult sym public class CosmosShellOptions { + public string? Output { get; set; } + + public bool Quiet { get; set; } + public int ColorSystem { get; set; } = 2; public string? ExecuteAndQuit { get; set; } diff --git a/CosmosDBShell/lang/en.ftl b/CosmosDBShell/lang/en.ftl index 4ee120f8..e5fa196d 100644 --- a/CosmosDBShell/lang/en.ftl +++ b/CosmosDBShell/lang/en.ftl @@ -85,6 +85,8 @@ help-commands = Commands: help-examples = Examples: help-examples-heading = Examples help-aliases = Aliases: +help-OutputFormat = The output format to use (json, ndjson, table, csv). +help-Quiet = Suppresses standard informational output. command-help-description = Shows help information for commands command-help-description-command = The specific command to get help for diff --git a/README.md b/README.md index ae57ffab..61f88c75 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Azure Cosmos DB Shell +# Azure Cosmos DB Shell A terminal-native shell for Azure Cosmos DB — navigate databases like a filesystem, query interactively, and script with variables, loops, and functions. Supports Entra ID, MCP for AI tool integration, and works with the local emulator. @@ -161,6 +161,8 @@ Packaging runs produce preview versions in the form `1.0.-preview.` | Option | Description | | ------ | ----------- | +| `--output ` | The output format to use (`json`, `ndjson`, `table`, `csv`). Alias: `-o` | +| `--quiet` | Suppress standard informational output. Alias: `-q` | | `-c ` | Execute and exit | | `-k ` | Execute and stay | | `--connect ` | Connection string or endpoint URL | @@ -192,6 +194,18 @@ cosmosdbshell --connect "AccountEndpoint=...;AccountKey=..." -c seed.csh mydb my echo "seed.csh mydb mycontainer" | cosmosdbshell --connect "AccountEndpoint=...;AccountKey=..." ``` +## Deterministic Exit Codes + +When running scripts or automation, Cosmos DB Shell maps execution failures to a set of stable exit codes (accessible via `$?`, `%ERRORLEVEL%`, or `$LASTEXITCODE`): + +- **`0`**: Success +- **`1`**: Generic error +- **`2`**: Bad arguments / parser errors +- **`3`**: Auth / connection failure +- **`4`**: Not found +- **`5`**: Throttled (RU budget exceeded) +> **Machine Mode**: Using `--output json`, `--output ndjson`, or `--quiet` (or running `-c` without specifying `--output`) disables ANSI colors, suppresses connection/informational banners, and redirects early parser/connection exceptions to `STDERR` as structured JSON. Data-operation command output is intended to remain structured on `STDOUT` for parser pipelines; diagnostic or explicitly interactive commands may still emit plain text. + ## Theming The shell ships with four built-in color profiles that can be selected at startup or swapped at runtime: