using System.Text.Json; using Spectre.Console; namespace StellaOps.Cli.Output; /// /// Helper for rendering CLI errors consistently. /// CLI-SDK-62-002: Provides standardized error output with error.code and trace_id. /// internal static class CliErrorRenderer { private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; /// /// Renders an error to the console. /// public static void Render(CliError error, bool verbose = false, bool asJson = false) { if (asJson) { RenderJson(error); } else { RenderConsole(error, verbose); } } /// /// Renders an error as JSON. /// public static void RenderJson(CliError error) { var output = new { error = new { code = error.Code, message = error.Message, detail = error.Detail, target = error.Target, help_url = error.HelpUrl, retry_after = error.RetryAfter, metadata = error.Metadata }, trace_id = error.TraceId, request_id = error.RequestId, exit_code = error.ExitCode }; var json = JsonSerializer.Serialize(output, JsonOptions); AnsiConsole.WriteLine(json); } /// /// Renders an error to the console with formatting. /// public static void RenderConsole(CliError error, bool verbose = false) { // Main error message AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(error.Message)}"); // Error code AnsiConsole.MarkupLine($"[grey]Code:[/] {Markup.Escape(error.Code)}"); if (TryGetReasonCode(error, out var reasonCode)) { AnsiConsole.MarkupLine($"[grey]Reason:[/] {Markup.Escape(reasonCode)}"); } // Detail (if present) if (!string.IsNullOrWhiteSpace(error.Detail)) { AnsiConsole.MarkupLine($"[grey]Detail:[/] {Markup.Escape(error.Detail)}"); } // Target (if present) if (!string.IsNullOrWhiteSpace(error.Target)) { AnsiConsole.MarkupLine($"[grey]Target:[/] {Markup.Escape(error.Target)}"); } // Help URL (if present) if (!string.IsNullOrWhiteSpace(error.HelpUrl)) { AnsiConsole.MarkupLine($"[grey]Help:[/] [link]{Markup.Escape(error.HelpUrl)}[/]"); } // Retry-after (if present) if (error.RetryAfter.HasValue) { AnsiConsole.MarkupLine($"[yellow]Retry after:[/] {error.RetryAfter} seconds"); } // Trace/Request IDs (shown in verbose mode or always for debugging) if (verbose || !string.IsNullOrWhiteSpace(error.TraceId)) { AnsiConsole.WriteLine(); if (!string.IsNullOrWhiteSpace(error.TraceId)) { AnsiConsole.MarkupLine($"[grey]Trace ID:[/] {Markup.Escape(error.TraceId)}"); } if (!string.IsNullOrWhiteSpace(error.RequestId)) { AnsiConsole.MarkupLine($"[grey]Request ID:[/] {Markup.Escape(error.RequestId)}"); } } // Metadata (shown in verbose mode) if (verbose && error.Metadata is not null && error.Metadata.Count > 0) { AnsiConsole.WriteLine(); AnsiConsole.MarkupLine("[grey]Metadata:[/]"); foreach (var (key, value) in error.Metadata) { AnsiConsole.MarkupLine($" [grey]{Markup.Escape(key)}:[/] {Markup.Escape(value)}"); } } } /// /// Renders a simple error message. /// public static void RenderSimple(string message, string? code = null) { AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(message)}"); if (!string.IsNullOrWhiteSpace(code)) { AnsiConsole.MarkupLine($"[grey]Code:[/] {Markup.Escape(code)}"); } } /// /// Renders a warning message. /// public static void RenderWarning(string message) { AnsiConsole.MarkupLine($"[yellow]Warning:[/] {Markup.Escape(message)}"); } /// /// Renders scope guidance for invalid_scope errors. /// public static void RenderScopeGuidance(CliError error) { if (!error.Code.Contains("SCOPE", StringComparison.OrdinalIgnoreCase)) return; AnsiConsole.WriteLine(); AnsiConsole.MarkupLine("[yellow]The requested operation requires additional OAuth scopes.[/]"); AnsiConsole.WriteLine(); AnsiConsole.MarkupLine("To resolve this issue:"); AnsiConsole.MarkupLine(" 1. Check your CLI profile configuration: [cyan]stella profile show[/]"); AnsiConsole.MarkupLine(" 2. Update your profile with required scopes: [cyan]stella profile edit [/]"); AnsiConsole.MarkupLine(" 3. Request additional scopes from your administrator"); AnsiConsole.MarkupLine(" 4. Re-authenticate with the updated scopes: [cyan]stella auth login[/]"); } /// /// Renders rate limit guidance. /// public static void RenderRateLimitGuidance(CliError error) { if (!error.Code.Contains("RATE_LIMIT", StringComparison.OrdinalIgnoreCase)) return; AnsiConsole.WriteLine(); AnsiConsole.MarkupLine("[yellow]Rate limit exceeded.[/]"); if (error.RetryAfter.HasValue) { AnsiConsole.MarkupLine($"Please wait [cyan]{error.RetryAfter}[/] seconds before retrying."); } else { AnsiConsole.MarkupLine("Please wait before retrying your request."); } } /// /// Renders authentication guidance. /// public static void RenderAuthGuidance(CliError error) { if (!error.Code.StartsWith("ERR_AUTH_", StringComparison.OrdinalIgnoreCase)) return; AnsiConsole.WriteLine(); if (error.Code == CliErrorCodes.Unauthorized) { AnsiConsole.MarkupLine("[yellow]Authentication required.[/]"); AnsiConsole.MarkupLine("Please authenticate using: [cyan]stella auth login[/]"); } else if (error.Code == CliErrorCodes.Forbidden) { AnsiConsole.MarkupLine("[yellow]Access denied.[/]"); AnsiConsole.MarkupLine("You do not have permission to perform this operation."); AnsiConsole.MarkupLine("Contact your administrator to request access."); } } /// /// Renders contextual guidance based on error code. /// public static void RenderGuidance(CliError error) { RenderScopeGuidance(error); RenderRateLimitGuidance(error); RenderAuthGuidance(error); RenderOfflineKitGuidance(error); } private static bool TryGetReasonCode(CliError error, out string reasonCode) { reasonCode = ""; if (error.Metadata is null || error.Metadata.Count == 0) { return false; } string? code1 = null; string? code2 = null; if ((!error.Metadata.TryGetValue("reason_code", out code1) || string.IsNullOrWhiteSpace(code1)) && (!error.Metadata.TryGetValue("reasonCode", out code2) || string.IsNullOrWhiteSpace(code2))) { return false; } reasonCode = OfflineKitReasonCodes.Normalize(code1 ?? code2 ?? "") ?? ""; return reasonCode.Length > 0; } private static void RenderOfflineKitGuidance(CliError error) { if (!TryGetReasonCode(error, out var reasonCode)) { return; } var remediation = OfflineKitReasonCodes.GetRemediation(reasonCode); if (string.IsNullOrWhiteSpace(remediation)) { return; } AnsiConsole.WriteLine(); AnsiConsole.MarkupLine($"[yellow]Remediation:[/] {Markup.Escape(remediation)}"); } }