diff --git a/src/PlanViewer.Cli/Commands/AnalyzeCommand.cs b/src/PlanViewer.Cli/Commands/AnalyzeCommand.cs index 39252fb..0558463 100644 --- a/src/PlanViewer.Cli/Commands/AnalyzeCommand.cs +++ b/src/PlanViewer.Cli/Commands/AnalyzeCommand.cs @@ -1,5 +1,4 @@ using System.CommandLine; -using System.CommandLine.Invocation; using System.Diagnostics; using System.Text.Json; using Microsoft.Data.SqlClient; @@ -27,83 +26,94 @@ public static class AnalyzeCommand public static Command Create(ICredentialService? credentialService = null) { - var fileArg = new Argument( - "file", - description: "Path to a .sqlplan file, .sql file, or directory of .sql files") + var fileArg = new Argument("file") { + Description = "Path to a .sqlplan file, .sql file, or directory of .sql files", Arity = ArgumentArity.ZeroOrOne }; - var stdinOption = new Option( - "--stdin", - "Read plan XML from stdin"); + var stdinOption = new Option("--stdin") + { + Description = "Read plan XML from stdin" + }; - var outputOption = new Option( - "--output", - getDefaultValue: () => "json", - description: "Output format: json or text"); - outputOption.AddAlias("-o"); + var outputOption = new Option("--output", "-o") + { + Description = "Output format: json or text", + DefaultValueFactory = _ => "json" + }; - var compactOption = new Option( - "--compact", - "Compact JSON output (no indentation)"); + var compactOption = new Option("--compact") + { + Description = "Compact JSON output (no indentation)" + }; - var warningsOnlyOption = new Option( - "--warnings-only", - "Only output warnings and missing indexes, skip operator tree"); + var warningsOnlyOption = new Option("--warnings-only") + { + Description = "Only output warnings and missing indexes, skip operator tree" + }; // Live execution options - var serverOption = new Option( - "--server", - "Server name (matches credential store key)"); - serverOption.AddAlias("-s"); - - var databaseOption = new Option( - "--database", - "Database context for execution"); - databaseOption.AddAlias("-d"); - - var queryOption = new Option( - "--query", - "Inline SQL text to execute"); - queryOption.AddAlias("-q"); - - var outputDirOption = new Option( - "--output-dir", - "Directory for output files (default: current directory)"); - - var estimatedOption = new Option( - "--estimated", - "Use estimated plan (SET SHOWPLAN XML ON) instead of actual plan"); - - var authOption = new Option( - "--auth", - "Authentication type: windows, sql, entra (default: auto-detect)"); - - var trustCertOption = new Option( - "--trust-cert", - "Trust the server certificate (for dev/test environments)"); - - var timeoutOption = new Option( - "--timeout", - getDefaultValue: () => 60, - description: "Query timeout in seconds"); - - var loginOption = new Option( - "--login", - "SQL Server login name (bypasses credential store)"); - - var passwordOption = new Option( - "--password", - "SQL Server password (bypasses credential store). Visible in process listings — prefer --password-stdin."); - - var passwordStdinOption = new Option( - "--password-stdin", - "Read the SQL Server password from stdin (avoids process-listing exposure). Mutually exclusive with --password."); - - var configOption = new Option( - "--config", - "Path to .planview.json config file (overrides auto-discovery)"); + var serverOption = new Option("--server", "-s") + { + Description = "Server name (matches credential store key)" + }; + + var databaseOption = new Option("--database", "-d") + { + Description = "Database context for execution" + }; + + var queryOption = new Option("--query", "-q") + { + Description = "Inline SQL text to execute" + }; + + var outputDirOption = new Option("--output-dir") + { + Description = "Directory for output files (default: current directory)" + }; + + var estimatedOption = new Option("--estimated") + { + Description = "Use estimated plan (SET SHOWPLAN XML ON) instead of actual plan" + }; + + var authOption = new Option("--auth") + { + Description = "Authentication type: windows, sql, entra (default: auto-detect)" + }; + + var trustCertOption = new Option("--trust-cert") + { + Description = "Trust the server certificate (for dev/test environments)" + }; + + var timeoutOption = new Option("--timeout") + { + Description = "Query timeout in seconds", + DefaultValueFactory = _ => 60 + }; + + var loginOption = new Option("--login") + { + Description = "SQL Server login name (bypasses credential store)" + }; + + var passwordOption = new Option("--password") + { + Description = "SQL Server password (bypasses credential store). Visible in process listings — prefer --password-stdin." + }; + + var passwordStdinOption = new Option("--password-stdin") + { + Description = "Read the SQL Server password from stdin (avoids process-listing exposure). Mutually exclusive with --password." + }; + + var configOption = new Option("--config") + { + Description = "Path to .planview.json config file (overrides auto-discovery)" + }; var cmd = new Command("analyze", "Analyze a SQL Server execution plan") { @@ -126,25 +136,25 @@ public static Command Create(ICredentialService? credentialService = null) configOption }; - cmd.SetHandler(async (ctx) => - { - var file = ctx.ParseResult.GetValueForArgument(fileArg); - var stdin = ctx.ParseResult.GetValueForOption(stdinOption); - var output = ctx.ParseResult.GetValueForOption(outputOption) ?? "json"; - var compact = ctx.ParseResult.GetValueForOption(compactOption); - var warningsOnly = ctx.ParseResult.GetValueForOption(warningsOnlyOption); - var server = ctx.ParseResult.GetValueForOption(serverOption); - var database = ctx.ParseResult.GetValueForOption(databaseOption); - var query = ctx.ParseResult.GetValueForOption(queryOption); - var outputDir = ctx.ParseResult.GetValueForOption(outputDirOption); - var estimated = ctx.ParseResult.GetValueForOption(estimatedOption); - var auth = ctx.ParseResult.GetValueForOption(authOption); - var trustCert = ctx.ParseResult.GetValueForOption(trustCertOption); - var timeout = ctx.ParseResult.GetValueForOption(timeoutOption); - var login = ctx.ParseResult.GetValueForOption(loginOption); - var passwordInline = ctx.ParseResult.GetValueForOption(passwordOption); - var passwordStdin = ctx.ParseResult.GetValueForOption(passwordStdinOption); - var configPath = ctx.ParseResult.GetValueForOption(configOption); + cmd.SetAction(async (parseResult, ct) => + { + var file = parseResult.GetValue(fileArg); + var stdin = parseResult.GetValue(stdinOption); + var output = parseResult.GetValue(outputOption) ?? "json"; + var compact = parseResult.GetValue(compactOption); + var warningsOnly = parseResult.GetValue(warningsOnlyOption); + var server = parseResult.GetValue(serverOption); + var database = parseResult.GetValue(databaseOption); + var query = parseResult.GetValue(queryOption); + var outputDir = parseResult.GetValue(outputDirOption); + var estimated = parseResult.GetValue(estimatedOption); + var auth = parseResult.GetValue(authOption); + var trustCert = parseResult.GetValue(trustCertOption); + var timeout = parseResult.GetValue(timeoutOption); + var login = parseResult.GetValue(loginOption); + var passwordInline = parseResult.GetValue(passwordOption); + var passwordStdin = parseResult.GetValue(passwordStdinOption); + var configPath = parseResult.GetValue(configOption); // Load analyzer config var analyzerConfig = ConfigLoader.Load(configPath); diff --git a/src/PlanViewer.Cli/Commands/CredentialCommand.cs b/src/PlanViewer.Cli/Commands/CredentialCommand.cs index e49b9f1..f864680 100644 --- a/src/PlanViewer.Cli/Commands/CredentialCommand.cs +++ b/src/PlanViewer.Cli/Commands/CredentialCommand.cs @@ -1,157 +1,173 @@ -using System.CommandLine; -using System.Text; -using PlanViewer.Core.Interfaces; -using PlanViewer.Core.Services; - -namespace PlanViewer.Cli.Commands; - -public static class CredentialCommand -{ - public static Command Create(ICredentialService credentialService) - { - var cmd = new Command("credential", "Manage stored server credentials"); - - cmd.AddCommand(CreateAddCommand(credentialService)); - cmd.AddCommand(CreateListCommand(credentialService)); - cmd.AddCommand(CreateRemoveCommand(credentialService)); - - return cmd; - } - - private static Command CreateAddCommand(ICredentialService credentialService) - { - var serverArg = new Argument("server-name", "Server name to store credentials for"); - var userOption = new Option("--user", "Username") { IsRequired = true }; - userOption.AddAlias("-u"); - var passwordOption = new Option("--password", "Password (if omitted, prompts interactively)"); - passwordOption.AddAlias("-p"); - - var cmd = new Command("add", "Add or update credentials for a server") - { - serverArg, userOption, passwordOption - }; - - cmd.SetHandler((string server, string user, string? passwordArg) => - { - string password; - if (!string.IsNullOrEmpty(passwordArg)) - { - Console.Error.WriteLine( - "Warning: --password is visible in process listings and shell history. " + - "Prefer piping the password into stdin (e.g. `echo hunter2 | planview credential add ...`)."); - password = passwordArg; - } - else if (Console.IsInputRedirected) - { - password = Console.In.ReadLine() ?? ""; - } - else - { - Console.Write("Password: "); - password = ReadPasswordMasked(); - Console.WriteLine(); - } - - if (string.IsNullOrEmpty(password)) - { - Console.Error.WriteLine("Password cannot be empty"); - Environment.ExitCode = 1; - return; - } - - if (credentialService.SaveCredential(server, user, password)) - Console.WriteLine($"Credential saved for {server}"); - else - { - Console.Error.WriteLine($"Failed to save credential for {server}"); - Environment.ExitCode = 1; - } - }, serverArg, userOption, passwordOption); - - return cmd; - } - - private static Command CreateListCommand(ICredentialService credentialService) - { - var cmd = new Command("list", "List stored credentials"); - - cmd.SetHandler(() => - { - IReadOnlyList<(string ServerName, string Username)>? creds = null; - // CA1416: WindowsCredentialService is gated on OperatingSystem.IsWindows(). - // .NET 8 won't run below Windows 10, so the underlying "windows5.1.2600" requirement is always met. -#pragma warning disable CA1416 - if (OperatingSystem.IsWindows() && credentialService is WindowsCredentialService win) - creds = win.ListAll(); -#pragma warning restore CA1416 - if (OperatingSystem.IsMacOS() && credentialService is KeychainCredentialService mac) - creds = mac.ListAll(); - - if (creds == null) - { - Console.Error.WriteLine("Credential listing not supported on this platform"); - return; - } - - if (creds.Count == 0) - { - Console.WriteLine("No stored credentials"); - return; - } - - Console.WriteLine($"{"Server",-40} {"Username",-30}"); - Console.WriteLine(new string('-', 70)); - foreach (var (server, username) in creds) - Console.WriteLine($"{server,-40} {username,-30}"); - }); - - return cmd; - } - - private static Command CreateRemoveCommand(ICredentialService credentialService) - { - var serverArg = new Argument("server-name", "Server name to remove credentials for"); - - var cmd = new Command("remove", "Remove stored credentials for a server") - { - serverArg - }; - - cmd.SetHandler((string server) => - { - if (credentialService.CredentialExists(server)) - { - credentialService.DeleteCredential(server); - Console.WriteLine($"Credential removed for {server}"); - } - else - { - Console.Error.WriteLine($"No credential found for {server}"); - Environment.ExitCode = 1; - } - }, serverArg); - - return cmd; - } - - private static string ReadPasswordMasked() - { - var password = new StringBuilder(); - while (true) - { - var key = Console.ReadKey(intercept: true); - if (key.Key == ConsoleKey.Enter) break; - if (key.Key == ConsoleKey.Backspace && password.Length > 0) - { - password.Length--; - Console.Write("\b \b"); - } - else if (!char.IsControl(key.KeyChar)) - { - password.Append(key.KeyChar); - Console.Write('*'); - } - } - return password.ToString(); - } -} +using System.CommandLine; +using System.Text; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Services; + +namespace PlanViewer.Cli.Commands; + +public static class CredentialCommand +{ + public static Command Create(ICredentialService credentialService) + { + var cmd = new Command("credential", "Manage stored server credentials"); + + cmd.Subcommands.Add(CreateAddCommand(credentialService)); + cmd.Subcommands.Add(CreateListCommand(credentialService)); + cmd.Subcommands.Add(CreateRemoveCommand(credentialService)); + + return cmd; + } + + private static Command CreateAddCommand(ICredentialService credentialService) + { + var serverArg = new Argument("server-name") + { + Description = "Server name to store credentials for" + }; + var userOption = new Option("--user", "-u") + { + Description = "Username", + Required = true + }; + var passwordOption = new Option("--password", "-p") + { + Description = "Password (if omitted, prompts interactively)" + }; + + var cmd = new Command("add", "Add or update credentials for a server") + { + serverArg, userOption, passwordOption + }; + + cmd.SetAction(parseResult => + { + var server = parseResult.GetValue(serverArg)!; + var user = parseResult.GetValue(userOption)!; + var passwordArg = parseResult.GetValue(passwordOption); + + string password; + if (!string.IsNullOrEmpty(passwordArg)) + { + Console.Error.WriteLine( + "Warning: --password is visible in process listings and shell history. " + + "Prefer piping the password into stdin (e.g. `echo hunter2 | planview credential add ...`)."); + password = passwordArg; + } + else if (Console.IsInputRedirected) + { + password = Console.In.ReadLine() ?? ""; + } + else + { + Console.Write("Password: "); + password = ReadPasswordMasked(); + Console.WriteLine(); + } + + if (string.IsNullOrEmpty(password)) + { + Console.Error.WriteLine("Password cannot be empty"); + Environment.ExitCode = 1; + return; + } + + if (credentialService.SaveCredential(server, user, password)) + Console.WriteLine($"Credential saved for {server}"); + else + { + Console.Error.WriteLine($"Failed to save credential for {server}"); + Environment.ExitCode = 1; + } + }); + + return cmd; + } + + private static Command CreateListCommand(ICredentialService credentialService) + { + var cmd = new Command("list", "List stored credentials"); + + cmd.SetAction(_ => + { + IReadOnlyList<(string ServerName, string Username)>? creds = null; + // CA1416: WindowsCredentialService is gated on OperatingSystem.IsWindows(). + // .NET 8 won't run below Windows 10, so the underlying "windows5.1.2600" requirement is always met. +#pragma warning disable CA1416 + if (OperatingSystem.IsWindows() && credentialService is WindowsCredentialService win) + creds = win.ListAll(); +#pragma warning restore CA1416 + if (OperatingSystem.IsMacOS() && credentialService is KeychainCredentialService mac) + creds = mac.ListAll(); + + if (creds == null) + { + Console.Error.WriteLine("Credential listing not supported on this platform"); + return; + } + + if (creds.Count == 0) + { + Console.WriteLine("No stored credentials"); + return; + } + + Console.WriteLine($"{"Server",-40} {"Username",-30}"); + Console.WriteLine(new string('-', 70)); + foreach (var (server, username) in creds) + Console.WriteLine($"{server,-40} {username,-30}"); + }); + + return cmd; + } + + private static Command CreateRemoveCommand(ICredentialService credentialService) + { + var serverArg = new Argument("server-name") + { + Description = "Server name to remove credentials for" + }; + + var cmd = new Command("remove", "Remove stored credentials for a server") + { + serverArg + }; + + cmd.SetAction(parseResult => + { + var server = parseResult.GetValue(serverArg)!; + if (credentialService.CredentialExists(server)) + { + credentialService.DeleteCredential(server); + Console.WriteLine($"Credential removed for {server}"); + } + else + { + Console.Error.WriteLine($"No credential found for {server}"); + Environment.ExitCode = 1; + } + }); + + return cmd; + } + + private static string ReadPasswordMasked() + { + var password = new StringBuilder(); + while (true) + { + var key = Console.ReadKey(intercept: true); + if (key.Key == ConsoleKey.Enter) break; + if (key.Key == ConsoleKey.Backspace && password.Length > 0) + { + password.Length--; + Console.Write("\b \b"); + } + else if (!char.IsControl(key.KeyChar)) + { + password.Append(key.KeyChar); + Console.Write('*'); + } + } + return password.ToString(); + } +} diff --git a/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs b/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs index 15baf18..a0fd96e 100644 --- a/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs +++ b/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs @@ -25,73 +25,111 @@ public static class QueryStoreCommand public static Command Create(ICredentialService? credentialService = null) { - var serverOption = new Option( - "--server", "SQL Server instance name") { IsRequired = true }; - serverOption.AddAlias("-s"); + var serverOption = new Option("--server", "-s") + { + Description = "SQL Server instance name", + Required = true + }; - var databaseOption = new Option( - "--database", "Database with Query Store enabled") { IsRequired = true }; - databaseOption.AddAlias("-d"); + var databaseOption = new Option("--database", "-d") + { + Description = "Database with Query Store enabled", + Required = true + }; - var topOption = new Option( - "--top", getDefaultValue: () => 25, - description: "Number of top queries to analyze"); + var topOption = new Option("--top") + { + Description = "Number of top queries to analyze", + DefaultValueFactory = _ => 25 + }; - var orderByOption = new Option( - "--order-by", getDefaultValue: () => "cpu", - description: "Ranking metric (total or avg): cpu, avg-cpu, duration, avg-duration, reads, avg-reads, writes, avg-writes, physical-reads, avg-physical-reads, memory, avg-memory, executions"); + var orderByOption = new Option("--order-by") + { + Description = "Ranking metric (total or avg): cpu, avg-cpu, duration, avg-duration, reads, avg-reads, writes, avg-writes, physical-reads, avg-physical-reads, memory, avg-memory, executions", + DefaultValueFactory = _ => "cpu" + }; - var hoursBackOption = new Option( - "--hours-back", getDefaultValue: () => 24, - description: "Hours of history to analyze"); + var hoursBackOption = new Option("--hours-back") + { + Description = "Hours of history to analyze", + DefaultValueFactory = _ => 24 + }; - var outputDirOption = new Option( - "--output-dir", "Directory for output files (default: current directory)"); + var outputDirOption = new Option("--output-dir") + { + Description = "Directory for output files (default: current directory)" + }; - var outputOption = new Option( - "--output", getDefaultValue: () => "text", - description: "Output format: json or text"); - outputOption.AddAlias("-o"); + var outputOption = new Option("--output", "-o") + { + Description = "Output format: json or text", + DefaultValueFactory = _ => "text" + }; - var compactOption = new Option( - "--compact", "Compact JSON output"); + var compactOption = new Option("--compact") + { + Description = "Compact JSON output" + }; - var warningsOnlyOption = new Option( - "--warnings-only", "Skip operator tree in output"); + var warningsOnlyOption = new Option("--warnings-only") + { + Description = "Skip operator tree in output" + }; - var configOption = new Option( - "--config", "Path to .planview.json config file"); + var configOption = new Option("--config") + { + Description = "Path to .planview.json config file" + }; - var authOption = new Option( - "--auth", "Authentication: windows, sql, entra"); + var authOption = new Option("--auth") + { + Description = "Authentication: windows, sql, entra" + }; - var trustCertOption = new Option( - "--trust-cert", "Trust the server certificate"); + var trustCertOption = new Option("--trust-cert") + { + Description = "Trust the server certificate" + }; - var loginOption = new Option( - "--login", "SQL Server login (bypasses credential store)"); + var loginOption = new Option("--login") + { + Description = "SQL Server login (bypasses credential store)" + }; - var passwordOption = new Option( - "--password", "SQL Server password. Visible in process listings — prefer --password-stdin."); + var passwordOption = new Option("--password") + { + Description = "SQL Server password. Visible in process listings — prefer --password-stdin." + }; - var passwordStdinOption = new Option( - "--password-stdin", - "Read the SQL Server password from stdin (avoids process-listing exposure). Mutually exclusive with --password."); + var passwordStdinOption = new Option("--password-stdin") + { + Description = "Read the SQL Server password from stdin (avoids process-listing exposure). Mutually exclusive with --password." + }; - var queryIdOption = new Option( - "--query-id", "Filter by Query Store query ID"); + var queryIdOption = new Option("--query-id") + { + Description = "Filter by Query Store query ID" + }; - var planIdOption = new Option( - "--plan-id", "Filter by Query Store plan ID"); + var planIdOption = new Option("--plan-id") + { + Description = "Filter by Query Store plan ID" + }; - var queryHashOption = new Option( - "--query-hash", "Filter by query hash (hex, e.g. 0x1AB2C3D4)"); + var queryHashOption = new Option("--query-hash") + { + Description = "Filter by query hash (hex, e.g. 0x1AB2C3D4)" + }; - var planHashOption = new Option( - "--plan-hash", "Filter by query plan hash (hex, e.g. 0x1AB2C3D4)"); + var planHashOption = new Option("--plan-hash") + { + Description = "Filter by query plan hash (hex, e.g. 0x1AB2C3D4)" + }; - var moduleOption = new Option( - "--module", "Filter by module name (schema.name, supports % wildcards)"); + var moduleOption = new Option("--module") + { + Description = "Filter by module name (schema.name, supports % wildcards)" + }; var cmd = new Command("query-store", "Analyze top queries from Query Store") { @@ -101,28 +139,28 @@ public static Command Create(ICredentialService? credentialService = null) queryIdOption, planIdOption, queryHashOption, planHashOption, moduleOption }; - cmd.SetHandler(async (ctx) => + cmd.SetAction(async (parseResult, ct) => { - var server = ctx.ParseResult.GetValueForOption(serverOption)!; - var database = ctx.ParseResult.GetValueForOption(databaseOption)!; - var top = ctx.ParseResult.GetValueForOption(topOption); - var orderBy = ctx.ParseResult.GetValueForOption(orderByOption) ?? "cpu"; - var hoursBack = ctx.ParseResult.GetValueForOption(hoursBackOption); - var outputDir = ctx.ParseResult.GetValueForOption(outputDirOption); - var output = ctx.ParseResult.GetValueForOption(outputOption) ?? "text"; - var compact = ctx.ParseResult.GetValueForOption(compactOption); - var warningsOnly = ctx.ParseResult.GetValueForOption(warningsOnlyOption); - var configPath = ctx.ParseResult.GetValueForOption(configOption); - var auth = ctx.ParseResult.GetValueForOption(authOption); - var trustCert = ctx.ParseResult.GetValueForOption(trustCertOption); - var login = ctx.ParseResult.GetValueForOption(loginOption); - var passwordInline = ctx.ParseResult.GetValueForOption(passwordOption); - var passwordStdin = ctx.ParseResult.GetValueForOption(passwordStdinOption); - var filterQueryId = ctx.ParseResult.GetValueForOption(queryIdOption); - var filterPlanId = ctx.ParseResult.GetValueForOption(planIdOption); - var filterQueryHash = ctx.ParseResult.GetValueForOption(queryHashOption); - var filterPlanHash = ctx.ParseResult.GetValueForOption(planHashOption); - var filterModule = ctx.ParseResult.GetValueForOption(moduleOption); + var server = parseResult.GetValue(serverOption)!; + var database = parseResult.GetValue(databaseOption)!; + var top = parseResult.GetValue(topOption); + var orderBy = parseResult.GetValue(orderByOption) ?? "cpu"; + var hoursBack = parseResult.GetValue(hoursBackOption); + var outputDir = parseResult.GetValue(outputDirOption); + var output = parseResult.GetValue(outputOption) ?? "text"; + var compact = parseResult.GetValue(compactOption); + var warningsOnly = parseResult.GetValue(warningsOnlyOption); + var configPath = parseResult.GetValue(configOption); + var auth = parseResult.GetValue(authOption); + var trustCert = parseResult.GetValue(trustCertOption); + var login = parseResult.GetValue(loginOption); + var passwordInline = parseResult.GetValue(passwordOption); + var passwordStdin = parseResult.GetValue(passwordStdinOption); + var filterQueryId = parseResult.GetValue(queryIdOption); + var filterPlanId = parseResult.GetValue(planIdOption); + var filterQueryHash = parseResult.GetValue(queryHashOption); + var filterPlanHash = parseResult.GetValue(planHashOption); + var filterModule = parseResult.GetValue(moduleOption); // Load .env file if present (CLI args take precedence) var env = ConnectionHelper.LoadEnvFile(); diff --git a/src/PlanViewer.Cli/PlanViewer.Cli.csproj b/src/PlanViewer.Cli/PlanViewer.Cli.csproj index 0e6c7ec..4e52a52 100644 --- a/src/PlanViewer.Cli/PlanViewer.Cli.csproj +++ b/src/PlanViewer.Cli/PlanViewer.Cli.csproj @@ -19,7 +19,7 @@ - + diff --git a/src/PlanViewer.Cli/Program.cs b/src/PlanViewer.Cli/Program.cs index d8b5062..1232051 100644 --- a/src/PlanViewer.Cli/Program.cs +++ b/src/PlanViewer.Cli/Program.cs @@ -26,5 +26,5 @@ // System.CommandLine's InvokeAsync returns 0 for successful dispatch even when a // handler set Environment.ExitCode = 1 to signal a validation error. Honor either // signal so scripts can tell success from failure. -var code = await root.InvokeAsync(args); +var code = await root.Parse(args).InvokeAsync(); return code != 0 ? code : Environment.ExitCode;