From f13850d2d65f4cbd031da50805f2c42579e845ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 23:28:41 +0000 Subject: [PATCH 01/11] Initial plan From 0212bd96f05b6ff8c6a11e129355b089bec0e992 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 23:35:06 +0000 Subject: [PATCH 02/11] Implement core Terminal.Gui.Cli API Agent-Logs-Url: https://github.com/gui-cs/cli/sessions/90f515f0-dbc5-4dc5-b941-ecd32f33b83f Co-authored-by: tig <585482+tig@users.noreply.github.com> --- specs/constitution.md | 8 +- specs/library-spec.md | 15 + src/Terminal.Gui.Cli/AgentGuideCommand.cs | 47 ++ src/Terminal.Gui.Cli/ArgParser.cs | 425 ++++++++++++++++++ src/Terminal.Gui.Cli/CliHost.cs | 167 +++++++ src/Terminal.Gui.Cli/CliHostOptions.cs | 67 +++ src/Terminal.Gui.Cli/CommandKind.cs | 11 + .../CommandOptionDescriptor.cs | 10 + src/Terminal.Gui.Cli/CommandRegistry.cs | 48 ++ src/Terminal.Gui.Cli/CommandResult.cs | 15 + src/Terminal.Gui.Cli/CommandRunOptions.cs | 65 +++ src/Terminal.Gui.Cli/CommandStatus.cs | 17 + .../EmbeddedMarkdownHelpProvider.cs | 45 ++ src/Terminal.Gui.Cli/ExitCodes.cs | 41 ++ .../GlobalOptionDescriptor.cs | 9 + src/Terminal.Gui.Cli/HelpCommand.cs | 63 +++ src/Terminal.Gui.Cli/ICliCommand.cs | 41 ++ src/Terminal.Gui.Cli/ICliCommandGeneric.cs | 24 + src/Terminal.Gui.Cli/ICommandRegistry.cs | 17 + src/Terminal.Gui.Cli/IHelpProvider.cs | 11 + src/Terminal.Gui.Cli/IViewerCommand.cs | 17 + src/Terminal.Gui.Cli/InputCommandRunner.cs | 52 +++ src/Terminal.Gui.Cli/JsonEnvelope.cs | 61 +++ src/Terminal.Gui.Cli/MarkdownRenderer.cs | 17 + src/Terminal.Gui.Cli/MetadataHelpProvider.cs | 58 +++ src/Terminal.Gui.Cli/OpenCliWriter.cs | 167 +++++++ src/Terminal.Gui.Cli/ResultWriter.cs | 67 +++ .../TerminalEscapeSanitizer.cs | 115 +++++ src/Terminal.Gui.Cli/TypeNames.cs | 50 +++ .../Terminal.Gui.Cli.Tests/ArgParserTests.cs | 66 +++ tests/Terminal.Gui.Cli.Tests/CliHostTests.cs | 43 ++ .../CommandRegistryTests.cs | 48 ++ tests/Terminal.Gui.Cli.Tests/OutputTests.cs | 39 ++ 33 files changed, 1944 insertions(+), 2 deletions(-) create mode 100644 specs/library-spec.md create mode 100644 src/Terminal.Gui.Cli/AgentGuideCommand.cs create mode 100644 src/Terminal.Gui.Cli/ArgParser.cs create mode 100644 src/Terminal.Gui.Cli/CliHost.cs create mode 100644 src/Terminal.Gui.Cli/CliHostOptions.cs create mode 100644 src/Terminal.Gui.Cli/CommandKind.cs create mode 100644 src/Terminal.Gui.Cli/CommandOptionDescriptor.cs create mode 100644 src/Terminal.Gui.Cli/CommandRegistry.cs create mode 100644 src/Terminal.Gui.Cli/CommandResult.cs create mode 100644 src/Terminal.Gui.Cli/CommandRunOptions.cs create mode 100644 src/Terminal.Gui.Cli/CommandStatus.cs create mode 100644 src/Terminal.Gui.Cli/EmbeddedMarkdownHelpProvider.cs create mode 100644 src/Terminal.Gui.Cli/ExitCodes.cs create mode 100644 src/Terminal.Gui.Cli/GlobalOptionDescriptor.cs create mode 100644 src/Terminal.Gui.Cli/HelpCommand.cs create mode 100644 src/Terminal.Gui.Cli/ICliCommand.cs create mode 100644 src/Terminal.Gui.Cli/ICliCommandGeneric.cs create mode 100644 src/Terminal.Gui.Cli/ICommandRegistry.cs create mode 100644 src/Terminal.Gui.Cli/IHelpProvider.cs create mode 100644 src/Terminal.Gui.Cli/IViewerCommand.cs create mode 100644 src/Terminal.Gui.Cli/InputCommandRunner.cs create mode 100644 src/Terminal.Gui.Cli/JsonEnvelope.cs create mode 100644 src/Terminal.Gui.Cli/MarkdownRenderer.cs create mode 100644 src/Terminal.Gui.Cli/MetadataHelpProvider.cs create mode 100644 src/Terminal.Gui.Cli/OpenCliWriter.cs create mode 100644 src/Terminal.Gui.Cli/ResultWriter.cs create mode 100644 src/Terminal.Gui.Cli/TerminalEscapeSanitizer.cs create mode 100644 src/Terminal.Gui.Cli/TypeNames.cs create mode 100644 tests/Terminal.Gui.Cli.Tests/ArgParserTests.cs create mode 100644 tests/Terminal.Gui.Cli.Tests/CliHostTests.cs create mode 100644 tests/Terminal.Gui.Cli.Tests/CommandRegistryTests.cs create mode 100644 tests/Terminal.Gui.Cli.Tests/OutputTests.cs diff --git a/specs/constitution.md b/specs/constitution.md index 0dc4dd2..147c8ce 100644 --- a/specs/constitution.md +++ b/specs/constitution.md @@ -52,12 +52,16 @@ For versioned machine-readable contracts, `v1` schemas are append-only. Existing Warnings are treated as errors. Builds and CI must remain warning-free. -## IV. Testing Tiers +## IV. Public API Layout + +Public API is documented in `specs/library-spec.md`. Two narrow file-layout exceptions are intentional: `CommandResult` and `CommandResult` live together in `CommandResult.cs`, and `ICliCommand` lives in `ICliCommandGeneric.cs`. Do not use angle brackets in filenames; the `Generic` suffix is the established convention for this single generic-interface companion file. + +## V. Testing Tiers - `Terminal.Gui.Cli.Tests` — unit tests - `Terminal.Gui.Cli.IntegrationTests` — integration tests - `Terminal.Gui.Cli.SmokeTests` — smoke-level validation -## V. Governance +## VI. Governance Constitution changes require a pull request that updates this file and explains the rationale and migration impact. diff --git a/specs/library-spec.md b/specs/library-spec.md new file mode 100644 index 0000000..7a8195e --- /dev/null +++ b/specs/library-spec.md @@ -0,0 +1,15 @@ +# Terminal.Gui.Cli Library Specification + +This repository implements the `Terminal.Gui.Cli` package API described by issue "Terminal.Gui.Cli Library Specification". + +Public API additions must keep the following contracts aligned with implementation: + +- Command model: `CommandKind`, `CommandStatus`, `CommandOptionDescriptor`, `CommandResult`, and `CommandResult`. +- Command interfaces: `ICliCommand`, `ICliCommand`, and `IViewerCommand`. +- Registry: `ICommandRegistry` and `CommandRegistry` with case-insensitive alias resolution and duplicate rejection. +- Host and parser: `CliHost`, `CliHostOptions`, `CommandRunOptions`, `GlobalOptionDescriptor`, and `ArgParser`. +- Help and built-ins: `IHelpProvider`, `MetadataHelpProvider`, `EmbeddedMarkdownHelpProvider`, `HelpCommand`, and `AgentGuideCommand`. +- Output and metadata: `JsonEnvelope`, `ResultWriter`, `OpenCliWriter`, `ExitCodes`, `TypeNames`, `TerminalEscapeSanitizer`, and `MarkdownRenderer`. +- Input helper: `InputCommandRunner`. + +`CommandResult` and `CommandResult` intentionally live together in `CommandResult.cs`. `ICliCommand` intentionally lives in `ICliCommandGeneric.cs`; do not use angle brackets in filenames. diff --git a/src/Terminal.Gui.Cli/AgentGuideCommand.cs b/src/Terminal.Gui.Cli/AgentGuideCommand.cs new file mode 100644 index 0000000..673a17b --- /dev/null +++ b/src/Terminal.Gui.Cli/AgentGuideCommand.cs @@ -0,0 +1,47 @@ +using Terminal.Gui.App; + +namespace Terminal.Gui.Cli; + +/// Non-interactive viewer command that prints the consumer's agent guide. +public sealed class AgentGuideCommand : IViewerCommand +{ + private readonly string _markdown; + + /// Creates an agent guide command from resolved markdown content. + public AgentGuideCommand (string markdown) + { + _markdown = markdown ?? throw new ArgumentNullException (nameof (markdown)); + } + + /// + public string PrimaryAlias => "agent-guide"; + + /// + public IReadOnlyList Aliases { get; } = ["agent-guide"]; + + /// + public string Description => "Show the agent guide."; + + /// + public CommandKind Kind => CommandKind.Viewer; + + /// + public Type ResultType => typeof (string); + + /// + public IReadOnlyList Options { get; } = []; + + /// + public Task RunAsync (IApplication app, string? initial, CommandRunOptions options, CancellationToken cancellationToken) + { + return Task.FromResult (new CommandResult (CommandStatus.Ok, _markdown, null, null)); + } + + /// + public Task RenderCatAsync (CommandRunOptions options, TextWriter stdout, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull (stdout); + stdout.Write (_markdown); + return Task.FromResult (new CommandResult (CommandStatus.Ok, null, null, null)); + } +} diff --git a/src/Terminal.Gui.Cli/ArgParser.cs b/src/Terminal.Gui.Cli/ArgParser.cs new file mode 100644 index 0000000..c0be1ab --- /dev/null +++ b/src/Terminal.Gui.Cli/ArgParser.cs @@ -0,0 +1,425 @@ +using System.Globalization; + +namespace Terminal.Gui.Cli; + +/// Data-driven parser for framework flags, consumer globals, and per-command options. +public sealed class ArgParser +{ + private readonly IReadOnlyList _globalOptions; + private readonly int _maxInitialChars; + + /// Creates a parser with registered consumer globals and an --initial limit. + public ArgParser (List globalOptions, int maxInitialChars = 64 * 1024) + { + _globalOptions = globalOptions ?? throw new ArgumentNullException (nameof (globalOptions)); + _maxInitialChars = maxInitialChars; + } + + /// Parses command-line arguments, optionally validating against a resolved command. + public ParseResult Parse (string[] args, ICliCommand? command = null) + { + ArgumentNullException.ThrowIfNull (args); + + if (args.Length == 0) + { + return new ParseResult { Success = true, RootFlag = RootFlag.Help }; + } + + int index = 0; + + if (IsRootFlag (args[0], out RootFlag rootFlag)) + { + if (args.Length > 1) + { + return ParseResult.Fail ($"Unexpected argument '{args[1]}'."); + } + + return new ParseResult { Success = true, RootFlag = rootFlag }; + } + + var commandOptions = new Dictionary (StringComparer.OrdinalIgnoreCase); + var extensionValues = new Dictionary> (StringComparer.OrdinalIgnoreCase); + var arguments = new List (); + string? alias = null; + string? initial = null; + string? title = null; + bool json = false; + TimeSpan? timeout = null; + bool fullscreen = false; + bool cat = false; + string? outputPath = null; + int? rows = null; + + while (index < args.Length) + { + string token = args[index]; + + if (alias is null && !token.StartsWith ('-')) + { + alias = token; + index++; + continue; + } + + if (alias is not null && !token.StartsWith ('-')) + { + arguments.Add (token); + index++; + continue; + } + + if (TryParseFrameworkOption (args, ref index, token, ref initial, ref title, ref json, ref timeout, ref fullscreen, ref cat, ref outputPath, ref rows, out string? frameworkError)) + { + if (frameworkError is not null) + { + return ParseResult.Fail (frameworkError); + } + + continue; + } + + if (TryFindGlobalOption (token, out GlobalOptionDescriptor? globalOption)) + { + if (!AddOptionValue (args, ref index, token, globalOption!.Name, globalOption.IsFlag, globalOption.Repeatable, extensionValues, out string? extensionError)) + { + return ParseResult.Fail (extensionError ?? $"Invalid option '{token}'."); + } + + continue; + } + + if (command is not null && TryFindCommandOption (command, token, out CommandOptionDescriptor? commandOption)) + { + bool isFlag = commandOption!.ValueType == typeof (bool); + + if (!AddCommandOptionValue (args, ref index, token, commandOption.Name, isFlag, commandOptions, out string? commandError)) + { + return ParseResult.Fail (commandError ?? $"Invalid option '{token}'."); + } + + continue; + } + + return ParseResult.Fail ($"Unknown option '{token}'."); + } + + if (alias is null) + { + return ParseResult.Fail ("Missing command alias."); + } + + if (initial is not null && initial.Length > _maxInitialChars) + { + return ParseResult.Fail ($"--initial exceeds the maximum length of {_maxInitialChars} characters."); + } + + if (command is not null) + { + foreach (CommandOptionDescriptor option in command.Options) + { + if (option.Required && !commandOptions.ContainsKey (option.Name) && option.DefaultValue is null) + { + return ParseResult.Fail ($"Missing required option '--{option.Name}'."); + } + + if (!commandOptions.ContainsKey (option.Name) && option.DefaultValue is not null) + { + commandOptions.Add (option.Name, option.DefaultValue); + } + } + + if (!command.AcceptsPositionalArgs && arguments.Count > 0) + { + return ParseResult.Fail ($"Command '{command.PrimaryAlias}' does not accept positional arguments."); + } + } + + var extensions = extensionValues.ToDictionary ( + static pair => pair.Key, + static pair => (IReadOnlyList)pair.Value, + StringComparer.OrdinalIgnoreCase); + + var options = new CommandRunOptions + { + Initial = initial, + Title = title, + JsonOutput = json, + Timeout = timeout, + Fullscreen = fullscreen, + Cat = cat, + OutputPath = outputPath, + Rows = rows, + Arguments = arguments, + CommandOptions = commandOptions, + Extensions = extensions + }; + + return new ParseResult + { + Success = true, + Alias = alias, + Initial = initial, + Options = options + }; + } + + /// Parses duration strings accepted by --timeout: ms, s, m, h. + public static bool TryParseTimeout (string input, out TimeSpan timeout) + { + timeout = default; + + if (string.IsNullOrWhiteSpace (input)) + { + return false; + } + + string suffix = input.EndsWith ("ms", StringComparison.OrdinalIgnoreCase) ? "ms" : input[^1..].ToLowerInvariant (); + string numberText = suffix == "ms" ? input[..^2] : input[..^1]; + + if (!double.TryParse (numberText, NumberStyles.Float, CultureInfo.InvariantCulture, out double value) || value < 0) + { + return false; + } + + timeout = suffix switch + { + "ms" => TimeSpan.FromMilliseconds (value), + "s" => TimeSpan.FromSeconds (value), + "m" => TimeSpan.FromMinutes (value), + "h" => TimeSpan.FromHours (value), + _ => default + }; + + return timeout != default || value == 0; + } + + private static bool IsRootFlag (string token, out RootFlag rootFlag) + { + rootFlag = token switch + { + "--help" or "-h" => RootFlag.Help, + "--version" => RootFlag.Version, + "--opencli" => RootFlag.OpenCli, + _ => default + }; + + return token is "--help" or "-h" or "--version" or "--opencli"; + } + + private static bool TryParseFrameworkOption ( + string[] args, + ref int index, + string token, + ref string? initial, + ref string? title, + ref bool json, + ref TimeSpan? timeout, + ref bool fullscreen, + ref bool cat, + ref string? outputPath, + ref int? rows, + out string? error) + { + error = null; + + switch (token) + { + case "--initial": + return ReadValue (args, ref index, token, out initial, out error); + case "--title" or "-t" or "--prompt" or "-p": + return ReadValue (args, ref index, token, out title, out error); + case "--json": + json = true; + index++; + return true; + case "--timeout": + if (!ReadValue (args, ref index, token, out string? timeoutText, out error)) + { + return true; + } + + if (!TryParseTimeout (timeoutText, out TimeSpan parsedTimeout)) + { + error = $"Invalid timeout '{timeoutText}'."; + return true; + } + + timeout = parsedTimeout; + return true; + case "--fullscreen": + fullscreen = true; + index++; + return true; + case "--cat": + cat = true; + index++; + return true; + case "--output" or "-o": + return ReadValue (args, ref index, token, out outputPath, out error); + case "--rows": + if (!ReadValue (args, ref index, token, out string? rowsText, out error)) + { + return true; + } + + if (!int.TryParse (rowsText, NumberStyles.None, CultureInfo.InvariantCulture, out int parsedRows) || parsedRows <= 0) + { + error = $"Invalid rows value '{rowsText}'."; + return true; + } + + rows = parsedRows; + return true; + default: + return false; + } + } + + private bool TryFindGlobalOption (string token, out GlobalOptionDescriptor? option) + { + option = _globalOptions.FirstOrDefault (candidate => MatchesOption (token, candidate.Name, candidate.ShortName)); + return option is not null; + } + + private static bool TryFindCommandOption (ICliCommand command, string token, out CommandOptionDescriptor? option) + { + option = command.Options.FirstOrDefault (candidate => MatchesOption (token, candidate.Name, candidate.ShortName)); + return option is not null; + } + + private static bool MatchesOption (string token, string name, string? shortName) + { + return token.Equals ($"--{name}", StringComparison.OrdinalIgnoreCase) + || (shortName is not null && token.Equals ($"-{shortName}", StringComparison.OrdinalIgnoreCase)); + } + + private static bool AddOptionValue ( + string[] args, + ref int index, + string token, + string name, + bool isFlag, + bool repeatable, + Dictionary> values, + out string? error) + { + error = null; + + if (!repeatable && values.ContainsKey (name)) + { + error = $"Option '{token}' cannot be specified more than once."; + return false; + } + + string value; + + if (isFlag) + { + value = "true"; + index++; + } + else if (!ReadValue (args, ref index, token, out value!, out error)) + { + return false; + } + + if (!values.TryGetValue (name, out List? optionValues)) + { + optionValues = []; + values.Add (name, optionValues); + } + + optionValues.Add (value); + return true; + } + + private static bool AddCommandOptionValue ( + string[] args, + ref int index, + string token, + string name, + bool isFlag, + Dictionary values, + out string? error) + { + error = null; + + if (values.ContainsKey (name)) + { + error = $"Option '{token}' cannot be specified more than once."; + return false; + } + + string value; + + if (isFlag) + { + value = "true"; + index++; + } + else if (!ReadValue (args, ref index, token, out value!, out error)) + { + return false; + } + + values.Add (name, value); + return true; + } + + private static bool ReadValue (string[] args, ref int index, string token, out string value, out string? error) + { + value = string.Empty; + error = null; + + if (index + 1 >= args.Length) + { + error = $"Option '{token}' requires a value."; + return false; + } + + value = args[index + 1]; + index += 2; + return true; + } + + /// Represents the result of parsing arguments. + public sealed class ParseResult + { + /// True if parsing succeeded. + public bool Success { get; init; } + + /// Error message when parsing failed. + public string? Error { get; init; } + + /// The command alias, when this is not a root flag. + public string? Alias { get; init; } + + /// The parsed initial value. + public string? Initial { get; init; } + + /// The parsed options bag. + public CommandRunOptions? Options { get; init; } + + /// Root flag detected before command dispatch. + public RootFlag? RootFlag { get; init; } + + /// Creates a failed parse result. + public static ParseResult Fail (string error) + { + return new ParseResult { Success = false, Error = error }; + } + } + + /// Root flags that exit without command dispatch. + public enum RootFlag + { + /// Root --help or -h. + Help, + + /// Root --version. + Version, + + /// Root --opencli. + OpenCli + } +} diff --git a/src/Terminal.Gui.Cli/CliHost.cs b/src/Terminal.Gui.Cli/CliHost.cs new file mode 100644 index 0000000..61a7101 --- /dev/null +++ b/src/Terminal.Gui.Cli/CliHost.cs @@ -0,0 +1,167 @@ +using System.Reflection; +using Terminal.Gui.App; + +namespace Terminal.Gui.Cli; + +/// The main entry point. Owns parsing, dispatch, Terminal.Gui lifecycle, and output. +public sealed class CliHost +{ + private readonly CliHostOptions _options; + private readonly ArgParser _parser; + private readonly IHelpProvider _helpProvider; + + /// Creates a host, applies configuration, creates its registry, and registers built-ins. + public CliHost (Action? configure = null) + { + _options = new CliHostOptions (); + configure?.Invoke (_options); + _helpProvider = _options.HelpProvider ?? new MetadataHelpProvider (); + Registry = new CommandRegistry (); + RegisterBuiltIns (); + _parser = new ArgParser (_options.GlobalOptions, _options.MaxInitialChars); + } + + /// The command registry owned by this host. Register consumer commands before RunAsync. + public ICommandRegistry Registry { get; } + + /// Parses args, dispatches a command, writes output, and returns a process exit code. + public async Task RunAsync ( + string[] args, + CancellationToken cancellationToken = default, + TextWriter? stdout = null, + TextWriter? stderr = null) + { + stdout ??= Console.Out; + stderr ??= Console.Error; + + ArgParser.ParseResult initialParse = _parser.Parse (args); + + if (!initialParse.Success) + { + stderr.WriteLine (initialParse.Error); + return ExitCodes.UsageError; + } + + if (initialParse.RootFlag is { } rootFlag) + { + WriteRootFlag (rootFlag, stdout); + return ExitCodes.Ok; + } + + if (initialParse.Alias is null || !Registry.TryResolve (initialParse.Alias, out ICliCommand? command) || command is null) + { + stderr.WriteLine ($"Unknown command '{initialParse.Alias}'."); + return ExitCodes.UsageError; + } + + ArgParser.ParseResult parse = _parser.Parse (args, command); + + if (!parse.Success || parse.Options is null) + { + stderr.WriteLine (parse.Error); + return ExitCodes.UsageError; + } + + CommandRunOptions runOptions = parse.Options; + + if (runOptions.Initial is not null && !command.TryValidateInitial (runOptions.Initial, runOptions)) + { + stderr.WriteLine ("Invalid --initial value."); + return ExitCodes.ValidationError; + } + + using CancellationTokenSource? timeoutSource = runOptions.Timeout is null + ? null + : CancellationTokenSource.CreateLinkedTokenSource (cancellationToken); + + if (timeoutSource is not null && runOptions.Timeout is { } timeout) + { + timeoutSource.CancelAfter (timeout); + } + + CancellationToken effectiveToken = timeoutSource?.Token ?? cancellationToken; + + if (command is IViewerCommand viewer && runOptions.Cat) + { + CommandResult? catResult = await viewer.RenderCatAsync (runOptions, stdout, effectiveToken); + + if (catResult is not null) + { + return ExitCodes.FromResult (catResult.Value); + } + } + + CommandResult result = await RunWithTerminalGuiAsync (command, runOptions, effectiveToken); + + if (!ResultWriter.Write (result, runOptions.JsonOutput, stdout, stderr, runOptions.OutputPath)) + { + return ExitCodes.UsageError; + } + + return ExitCodes.FromResult (result); + } + + private async Task RunWithTerminalGuiAsync (ICliCommand command, CommandRunOptions runOptions, CancellationToken cancellationToken) + { + using IApplication app = Application.Create ().Init (); + return await command.RunAsync (app, runOptions.Initial, runOptions, cancellationToken); + } + + private void WriteRootFlag (ArgParser.RootFlag rootFlag, TextWriter stdout) + { + switch (rootFlag) + { + case ArgParser.RootFlag.Help: + stdout.WriteLine (_helpProvider.GetRootHelp (Registry) ?? new MetadataHelpProvider ().GetRootHelp (Registry)); + break; + case ArgParser.RootFlag.Version: + stdout.WriteLine ($"{_options.ApplicationName} {_options.Version ?? "0.0.0"}"); + break; + case ArgParser.RootFlag.OpenCli: + stdout.WriteLine (OpenCliWriter.Generate (Registry, _options)); + break; + } + } + + private void RegisterBuiltIns () + { + if (_options.BuiltInReplacements.TryGetValue ("help", out ICliCommand? helpReplacement)) + { + Registry.Register (helpReplacement); + } + else + { + Registry.Register (new HelpCommand (Registry, _helpProvider)); + } + + if (_options.BuiltInReplacements.TryGetValue ("agent-guide", out ICliCommand? agentGuideReplacement)) + { + Registry.Register (agentGuideReplacement); + } + else if (_options.AgentGuide is not null) + { + Registry.Register (new AgentGuideCommand (ResolveAgentGuide ())) ; + } + } + + private string ResolveAgentGuide () + { + if (!_options.AgentGuideIsResource) + { + return _options.AgentGuide ?? string.Empty; + } + + Assembly assembly = _options.ResourceAssembly ?? Assembly.GetEntryAssembly () + ?? throw new InvalidOperationException ("No assembly is available for resolving AgentGuide."); + + using Stream? stream = assembly.GetManifestResourceStream (_options.AgentGuide!); + + if (stream is null) + { + throw new InvalidOperationException ($"AgentGuide resource '{_options.AgentGuide}' was not found."); + } + + using var reader = new StreamReader (stream); + return reader.ReadToEnd (); + } +} diff --git a/src/Terminal.Gui.Cli/CliHostOptions.cs b/src/Terminal.Gui.Cli/CliHostOptions.cs new file mode 100644 index 0000000..62c25f9 --- /dev/null +++ b/src/Terminal.Gui.Cli/CliHostOptions.cs @@ -0,0 +1,67 @@ +using System.Reflection; + +namespace Terminal.Gui.Cli; + +/// Configuration options for . +public sealed class CliHostOptions +{ + private readonly Dictionary _builtInReplacements = new (StringComparer.OrdinalIgnoreCase); + + /// Application name shown in help, version output, and OpenCLI. + public string ApplicationName { get; set; } = "app"; + + /// Version string shown in --version and OpenCLI. Null uses 0.0.0. + public string? Version { get; set; } + + /// Custom help provider. Null uses . + public IHelpProvider? HelpProvider { get; set; } + + /// Maximum characters accepted by --initial. Default is 64 KiB. + public int MaxInitialChars { get; set; } = 64 * 1024; + + /// Agent guide embedded resource name or literal markdown. Null disables agent-guide. + public string? AgentGuide { get; set; } + + /// True when is an embedded resource name; false when literal content. + public bool AgentGuideIsResource { get; set; } = true; + + /// Assembly used to resolve embedded resources. Null falls back to . + public Assembly? ResourceAssembly { get; set; } + + /// Consumer-defined global options parsed into . + public List GlobalOptions { get; } = []; + + internal IReadOnlyDictionary BuiltInReplacements => _builtInReplacements; + + /// Replaces a library built-in command before it is registered. + /// Thrown when is not a replaceable built-in alias. + /// Thrown when the same built-in alias is replaced more than once. + public void ReplaceBuiltInCommand (string alias, ICliCommand replacement) + { + ArgumentException.ThrowIfNullOrWhiteSpace (alias); + ArgumentNullException.ThrowIfNull (replacement); + + if (!IsReplaceableBuiltIn (alias)) + { + throw new ArgumentException ($"'{alias}' is not a replaceable built-in alias.", nameof (alias)); + } + + if (!replacement.Aliases.Contains (alias, StringComparer.OrdinalIgnoreCase)) + { + throw new ArgumentException ($"Replacement for '{alias}' must include that alias.", nameof (replacement)); + } + + if (_builtInReplacements.ContainsKey (alias)) + { + throw new InvalidOperationException ($"Built-in alias '{alias}' was already replaced."); + } + + _builtInReplacements.Add (alias, replacement); + } + + private static bool IsReplaceableBuiltIn (string alias) + { + return alias.Equals ("help", StringComparison.OrdinalIgnoreCase) + || alias.Equals ("agent-guide", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Terminal.Gui.Cli/CommandKind.cs b/src/Terminal.Gui.Cli/CommandKind.cs new file mode 100644 index 0000000..4381b27 --- /dev/null +++ b/src/Terminal.Gui.Cli/CommandKind.cs @@ -0,0 +1,11 @@ +namespace Terminal.Gui.Cli; + +/// The two kinds of CLI commands the library knows about. +public enum CommandKind +{ + /// An interactive command that returns a typed value. + Input, + + /// An interactive or headless command that does not return a typed result value. + Viewer +} diff --git a/src/Terminal.Gui.Cli/CommandOptionDescriptor.cs b/src/Terminal.Gui.Cli/CommandOptionDescriptor.cs new file mode 100644 index 0000000..a6dacf4 --- /dev/null +++ b/src/Terminal.Gui.Cli/CommandOptionDescriptor.cs @@ -0,0 +1,10 @@ +namespace Terminal.Gui.Cli; + +/// Metadata descriptor for a per-command option. +public sealed record CommandOptionDescriptor ( + string Name, + string? ShortName, + Type ValueType, + string Description, + bool Required, + string? DefaultValue); diff --git a/src/Terminal.Gui.Cli/CommandRegistry.cs b/src/Terminal.Gui.Cli/CommandRegistry.cs new file mode 100644 index 0000000..92b1321 --- /dev/null +++ b/src/Terminal.Gui.Cli/CommandRegistry.cs @@ -0,0 +1,48 @@ +namespace Terminal.Gui.Cli; + +/// Default case-insensitive, duplicate-rejecting command registry. +public sealed class CommandRegistry : ICommandRegistry +{ + private readonly Dictionary _commandsByAlias = new (StringComparer.OrdinalIgnoreCase); + private readonly List _commands = []; + + /// + public IReadOnlyCollection All => _commands; + + /// + public void Register (ICliCommand command) + { + ArgumentNullException.ThrowIfNull (command); + + if (!command.Aliases.Contains (command.PrimaryAlias, StringComparer.OrdinalIgnoreCase)) + { + throw new InvalidOperationException ("PrimaryAlias must be present in Aliases."); + } + + foreach (string alias in command.Aliases) + { + if (string.IsNullOrWhiteSpace (alias)) + { + throw new InvalidOperationException ("Command aliases must not be empty."); + } + + if (_commandsByAlias.ContainsKey (alias)) + { + throw new InvalidOperationException ($"Alias '{alias}' is already registered."); + } + } + + _commands.Add (command); + + foreach (string alias in command.Aliases) + { + _commandsByAlias.Add (alias, command); + } + } + + /// + public bool TryResolve (string alias, out ICliCommand? command) + { + return _commandsByAlias.TryGetValue (alias, out command); + } +} diff --git a/src/Terminal.Gui.Cli/CommandResult.cs b/src/Terminal.Gui.Cli/CommandResult.cs new file mode 100644 index 0000000..c028f13 --- /dev/null +++ b/src/Terminal.Gui.Cli/CommandResult.cs @@ -0,0 +1,15 @@ +namespace Terminal.Gui.Cli; + +/// Non-generic result for dispatch and output formatting. +public readonly record struct CommandResult ( + CommandStatus Status, + object? Value, + string? ErrorCode, + string? ErrorMessage); + +/// Typed result returned by input commands. +public readonly record struct CommandResult ( + CommandStatus Status, + T? Value, + string? ErrorCode, + string? ErrorMessage); diff --git a/src/Terminal.Gui.Cli/CommandRunOptions.cs b/src/Terminal.Gui.Cli/CommandRunOptions.cs new file mode 100644 index 0000000..d5fda9e --- /dev/null +++ b/src/Terminal.Gui.Cli/CommandRunOptions.cs @@ -0,0 +1,65 @@ +namespace Terminal.Gui.Cli; + +/// Parsed options bag passed to commands. +public sealed class CommandRunOptions +{ + /// Pre-fill value for the View. + public string? Initial { get; init; } + + /// Title override for TUI chrome. --prompt/-p is an alias for --title/-t. + public string? Title { get; init; } + + /// Whether to emit the JSON envelope instead of plain text. + public bool JsonOutput { get; init; } + + /// Cancel after this duration. + public TimeSpan? Timeout { get; init; } + + /// Force fullscreen. Input commands otherwise default to inline. + public bool Fullscreen { get; init; } + + /// Render supported viewer content to stdout instead of launching the TUI. + public bool Cat { get; init; } + + /// Write successful command output to this file instead of stdout. + public string? OutputPath { get; init; } + + /// Constrain inline height. + public int? Rows { get; init; } + + /// Positional arguments after the alias. + public IReadOnlyList Arguments { get; init; } = []; + + /// Per-command option values keyed by long option name without dashes. + public IReadOnlyDictionary CommandOptions { get; init; } + = new Dictionary (); + + /// Consumer-registered global option values keyed by long option name without dashes. + public IReadOnlyDictionary> Extensions { get; init; } + = new Dictionary> (); + + /// Gets the last value for a single-value consumer extension, parsed by . + public T? GetExtension (string key, Func parser, T? defaultValue = default) + { + ArgumentNullException.ThrowIfNull (parser); + + if (!Extensions.TryGetValue (key, out IReadOnlyList? values) || values.Count == 0) + { + return defaultValue; + } + + return parser (values[^1]); + } + + /// Gets all values for a repeatable consumer extension. + public IReadOnlyList GetExtensionList (string key) + { + return Extensions.TryGetValue (key, out IReadOnlyList? values) ? values : []; + } + + /// Returns true when a consumer extension flag or value is present. + public bool HasExtension (string key) + { + return Extensions.ContainsKey (key); + } +} diff --git a/src/Terminal.Gui.Cli/CommandStatus.cs b/src/Terminal.Gui.Cli/CommandStatus.cs new file mode 100644 index 0000000..9cb95de --- /dev/null +++ b/src/Terminal.Gui.Cli/CommandStatus.cs @@ -0,0 +1,17 @@ +namespace Terminal.Gui.Cli; + +/// Outcome status of a command run. +public enum CommandStatus +{ + /// The command completed successfully. + Ok, + + /// The user or caller cancelled the command. + Cancelled, + + /// The command failed. + Error, + + /// The command completed but produced no result. + NoResult +} diff --git a/src/Terminal.Gui.Cli/EmbeddedMarkdownHelpProvider.cs b/src/Terminal.Gui.Cli/EmbeddedMarkdownHelpProvider.cs new file mode 100644 index 0000000..79242d4 --- /dev/null +++ b/src/Terminal.Gui.Cli/EmbeddedMarkdownHelpProvider.cs @@ -0,0 +1,45 @@ +using System.Reflection; +using System.Text; + +namespace Terminal.Gui.Cli; + +/// Reads embedded markdown resources for root, command, and agent help. +public sealed class EmbeddedMarkdownHelpProvider : IHelpProvider +{ + private readonly Assembly _resourceAssembly; + + /// Creates a provider that reads markdown resources from . + public EmbeddedMarkdownHelpProvider (Assembly resourceAssembly) + { + _resourceAssembly = resourceAssembly ?? throw new ArgumentNullException (nameof (resourceAssembly)); + } + + /// + public string? GetRootHelp (ICommandRegistry registry) + { + return GetMarkdownResource ("help.md"); + } + + /// + public string? GetCommandHelp (ICliCommand command) + { + ArgumentNullException.ThrowIfNull (command); + return GetMarkdownResource ($"{command.PrimaryAlias}.md"); + } + + /// Reads an embedded markdown resource by exact manifest resource name. + public string? GetMarkdownResource (string resourceName) + { + ArgumentException.ThrowIfNullOrWhiteSpace (resourceName); + + using Stream? stream = _resourceAssembly.GetManifestResourceStream (resourceName); + + if (stream is null) + { + return null; + } + + using var reader = new StreamReader (stream, Encoding.UTF8); + return reader.ReadToEnd (); + } +} diff --git a/src/Terminal.Gui.Cli/ExitCodes.cs b/src/Terminal.Gui.Cli/ExitCodes.cs new file mode 100644 index 0000000..5f334fc --- /dev/null +++ b/src/Terminal.Gui.Cli/ExitCodes.cs @@ -0,0 +1,41 @@ +namespace Terminal.Gui.Cli; + +/// POSIX-conventional exit codes. +public static class ExitCodes +{ + /// Success. + public const int Ok = 0; + + /// Successful command execution with no result. + public const int NoResult = 1; + + /// Usage error: bad command, bad option, or output-file creation failure. + public const int UsageError = 2; + + /// Validation error, equivalent to sysexits EX_DATAERR. + public const int ValidationError = 65; + + /// I/O error, equivalent to sysexits EX_IOERR. + public const int IoError = 74; + + /// Cancelled, equivalent to 128 + SIGINT. + public const int Cancelled = 130; + + /// Maps a command result to a process exit code. + public static int FromResult (CommandResult result) + { + return result.Status switch + { + CommandStatus.Ok => Ok, + CommandStatus.NoResult => NoResult, + CommandStatus.Cancelled => Cancelled, + CommandStatus.Error => result.ErrorCode switch + { + "validation" => ValidationError, + "io" => IoError, + _ => UsageError + }, + _ => UsageError + }; + } +} diff --git a/src/Terminal.Gui.Cli/GlobalOptionDescriptor.cs b/src/Terminal.Gui.Cli/GlobalOptionDescriptor.cs new file mode 100644 index 0000000..bc3d52c --- /dev/null +++ b/src/Terminal.Gui.Cli/GlobalOptionDescriptor.cs @@ -0,0 +1,9 @@ +namespace Terminal.Gui.Cli; + +/// Describes a consumer-defined global option. +public sealed record GlobalOptionDescriptor ( + string Name, + string? ShortName, + string Description, + bool IsFlag, + bool Repeatable = false); diff --git a/src/Terminal.Gui.Cli/HelpCommand.cs b/src/Terminal.Gui.Cli/HelpCommand.cs new file mode 100644 index 0000000..0e3fac7 --- /dev/null +++ b/src/Terminal.Gui.Cli/HelpCommand.cs @@ -0,0 +1,63 @@ +using Terminal.Gui.App; + +namespace Terminal.Gui.Cli; + +/// Interactive TUI markdown help viewer, with --cat support for ANSI stdout. +public sealed class HelpCommand : IViewerCommand +{ + private readonly ICommandRegistry _registry; + private readonly IHelpProvider _helpProvider; + + /// Creates a help command that lazily reads command metadata from . + public HelpCommand (ICommandRegistry registry, IHelpProvider helpProvider) + { + _registry = registry ?? throw new ArgumentNullException (nameof (registry)); + _helpProvider = helpProvider ?? throw new ArgumentNullException (nameof (helpProvider)); + } + + /// + public string PrimaryAlias => "help"; + + /// + public IReadOnlyList Aliases { get; } = ["help"]; + + /// + public string Description => "Show command help."; + + /// + public CommandKind Kind => CommandKind.Viewer; + + /// + public Type ResultType => typeof (void); + + /// + public IReadOnlyList Options { get; } = []; + + /// + public bool AcceptsPositionalArgs => true; + + /// + public Task RunAsync (IApplication app, string? initial, CommandRunOptions options, CancellationToken cancellationToken) + { + string markdown = ResolveHelp (options); + return Task.FromResult (new CommandResult (CommandStatus.Ok, markdown, null, null)); + } + + /// + public Task RenderCatAsync (CommandRunOptions options, TextWriter stdout, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull (stdout); + MarkdownRenderer.RenderToAnsi (ResolveHelp (options), stdout); + return Task.FromResult (new CommandResult (CommandStatus.Ok, null, null, null)); + } + + private string ResolveHelp (CommandRunOptions options) + { + if (options.Arguments.Count > 0 && _registry.TryResolve (options.Arguments[0], out ICliCommand? command) && command is not null) + { + return _helpProvider.GetCommandHelp (command) ?? new MetadataHelpProvider ().GetCommandHelp (command) ?? string.Empty; + } + + return _helpProvider.GetRootHelp (_registry) ?? new MetadataHelpProvider ().GetRootHelp (_registry) ?? string.Empty; + } +} diff --git a/src/Terminal.Gui.Cli/ICliCommand.cs b/src/Terminal.Gui.Cli/ICliCommand.cs new file mode 100644 index 0000000..6105eb2 --- /dev/null +++ b/src/Terminal.Gui.Cli/ICliCommand.cs @@ -0,0 +1,41 @@ +using Terminal.Gui.App; + +namespace Terminal.Gui.Cli; + +/// A CLI command backed by Terminal.Gui. Implemented by consumer apps and built-ins. +public interface ICliCommand +{ + /// The canonical alias shown in help and OpenCLI output. + string PrimaryAlias { get; } + + /// All aliases that resolve to this command. Must include . + IReadOnlyList Aliases { get; } + + /// Human-readable one-line command description. + string Description { get; } + + /// The command kind. + CommandKind Kind { get; } + + /// The CLR type of the value written to the JSON envelope, or . + Type ResultType { get; } + + /// Per-command options accepted by this command. + IReadOnlyList Options { get; } + + /// Whether this command consumes positional arguments. + bool AcceptsPositionalArgs => false; + + /// + /// Validates the --initial value before Terminal.Gui starts. The default permits any value; + /// commands override this method when they need command-specific validation. + /// + bool TryValidateInitial (string initial, CommandRunOptions options) => true; + + /// Runs the command after the host has initialized Terminal.Gui. + Task RunAsync ( + IApplication app, + string? initial, + CommandRunOptions options, + CancellationToken cancellationToken); +} diff --git a/src/Terminal.Gui.Cli/ICliCommandGeneric.cs b/src/Terminal.Gui.Cli/ICliCommandGeneric.cs new file mode 100644 index 0000000..ebfa88c --- /dev/null +++ b/src/Terminal.Gui.Cli/ICliCommandGeneric.cs @@ -0,0 +1,24 @@ +using Terminal.Gui.App; + +namespace Terminal.Gui.Cli; + +/// Typed command that returns a value. +public interface ICliCommand : ICliCommand +{ + /// Runs the command and returns a typed result. + new Task> RunAsync ( + IApplication app, + string? initial, + CommandRunOptions options, + CancellationToken cancellationToken); + + async Task ICliCommand.RunAsync ( + IApplication app, + string? initial, + CommandRunOptions options, + CancellationToken cancellationToken) + { + CommandResult result = await RunAsync (app, initial, options, cancellationToken); + return new (result.Status, result.Value, result.ErrorCode, result.ErrorMessage); + } +} diff --git a/src/Terminal.Gui.Cli/ICommandRegistry.cs b/src/Terminal.Gui.Cli/ICommandRegistry.cs new file mode 100644 index 0000000..936c398 --- /dev/null +++ b/src/Terminal.Gui.Cli/ICommandRegistry.cs @@ -0,0 +1,17 @@ +namespace Terminal.Gui.Cli; + +/// Manages alias-to-command lookup. +public interface ICommandRegistry +{ + /// Registers a command instance. + /// + /// Thrown when PrimaryAlias is not present in Aliases, or any alias is already registered. + /// + void Register (ICliCommand command); + + /// Resolves an alias case-insensitively. + bool TryResolve (string alias, out ICliCommand? command); + + /// All registered commands in registration order. + IReadOnlyCollection All { get; } +} diff --git a/src/Terminal.Gui.Cli/IHelpProvider.cs b/src/Terminal.Gui.Cli/IHelpProvider.cs new file mode 100644 index 0000000..329d850 --- /dev/null +++ b/src/Terminal.Gui.Cli/IHelpProvider.cs @@ -0,0 +1,11 @@ +namespace Terminal.Gui.Cli; + +/// Pluggable help rendering. +public interface IHelpProvider +{ + /// Renders root-level help. Return null to use generated fallback text. + string? GetRootHelp (ICommandRegistry registry); + + /// Renders per-command help. Return null to use generated fallback text. + string? GetCommandHelp (ICliCommand command); +} diff --git a/src/Terminal.Gui.Cli/IViewerCommand.cs b/src/Terminal.Gui.Cli/IViewerCommand.cs new file mode 100644 index 0000000..6e88196 --- /dev/null +++ b/src/Terminal.Gui.Cli/IViewerCommand.cs @@ -0,0 +1,17 @@ +namespace Terminal.Gui.Cli; + +/// +/// Viewer command. Viewers can be interactive TUI commands or headless content commands, +/// but they are invoked through the viewer path and default to fullscreen when a TUI is used. +/// +public interface IViewerCommand : ICliCommand +{ + /// + /// Renders content to stdout without launching the TUI. Called when --cat is set. + /// Return null to indicate --cat is not supported and normal TUI dispatch should continue. + /// + Task RenderCatAsync ( + CommandRunOptions options, + TextWriter stdout, + CancellationToken cancellationToken) => Task.FromResult (null); +} diff --git a/src/Terminal.Gui.Cli/InputCommandRunner.cs b/src/Terminal.Gui.Cli/InputCommandRunner.cs new file mode 100644 index 0000000..ccdd233 --- /dev/null +++ b/src/Terminal.Gui.Cli/InputCommandRunner.cs @@ -0,0 +1,52 @@ +using Terminal.Gui.App; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace Terminal.Gui.Cli; + +/// Shared boilerplate for input commands that wrap a control in RunnableWrapper. +public static class InputCommandRunner +{ + /// Configures, runs, and maps the result from an input command wrapper when raw result and output value differ. + public static async Task> RunAsync ( + IApplication app, + RunnableWrapper wrapper, + CommandRunOptions options, + string defaultTitle, + CancellationToken cancellationToken, + Func> resultMapper, + bool addEnterBinding = true) + where TControl : View, new() + { + ArgumentNullException.ThrowIfNull (app); + ArgumentNullException.ThrowIfNull (wrapper); + ArgumentNullException.ThrowIfNull (options); + ArgumentNullException.ThrowIfNull (resultMapper); + + wrapper.Title = options.Title ?? defaultTitle; + await app.RunAsync (wrapper, cancellationToken); + return resultMapper (wrapper.Result); + } + + /// Configures and runs a wrapper whose raw result is already the output value. + public static Task> RunAsync ( + IApplication app, + RunnableWrapper wrapper, + CommandRunOptions options, + string defaultTitle, + CancellationToken cancellationToken, + bool addEnterBinding = true) + where TControl : View, new() + { + return RunAsync ( + app, + wrapper, + options, + defaultTitle, + cancellationToken, + result => result is null + ? new CommandResult (CommandStatus.Cancelled, default, null, null) + : new CommandResult (CommandStatus.Ok, result, null, null), + addEnterBinding); + } +} diff --git a/src/Terminal.Gui.Cli/JsonEnvelope.cs b/src/Terminal.Gui.Cli/JsonEnvelope.cs new file mode 100644 index 0000000..fab6905 --- /dev/null +++ b/src/Terminal.Gui.Cli/JsonEnvelope.cs @@ -0,0 +1,61 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Terminal.Gui.Cli; + +/// The stable wire format for CLI output. +public sealed class JsonEnvelope +{ + /// Wire schema version. Always 1 for library major version 1.x. + public int SchemaVersion { get; init; } = 1; + + /// Status string: ok, cancelled, error, or no-result. + public string Status { get; init; } = "ok"; + + /// Result value. Omitted when null. + public object? Value { get; init; } + + /// Error code. Omitted when null. + public string? Code { get; init; } + + /// Error message. Omitted when null. + public string? Message { get; init; } + + /// Creates an ok envelope. + public static JsonEnvelope Ok (object? value = null) + { + return new JsonEnvelope { Status = "ok", Value = value }; + } + + /// Creates a cancelled envelope. + public static JsonEnvelope Cancelled () + { + return new JsonEnvelope { Status = "cancelled" }; + } + + /// Creates an error envelope. + public static JsonEnvelope Error (string code, string message) + { + return new JsonEnvelope { Status = "error", Code = code, Message = message }; + } + + /// Creates a no-result envelope. + public static JsonEnvelope NoResult () + { + return new JsonEnvelope { Status = "no-result" }; + } + + /// Serializes using the source-generated JSON context. + public string ToJson () + { + return JsonSerializer.Serialize (this, CliJsonContext.Default.JsonEnvelope); + } +} + +[JsonSourceGenerationOptions ( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable (typeof (JsonEnvelope))] +internal partial class CliJsonContext : JsonSerializerContext +{ +} diff --git a/src/Terminal.Gui.Cli/MarkdownRenderer.cs b/src/Terminal.Gui.Cli/MarkdownRenderer.cs new file mode 100644 index 0000000..cb6b612 --- /dev/null +++ b/src/Terminal.Gui.Cli/MarkdownRenderer.cs @@ -0,0 +1,17 @@ +using Terminal.Gui.Views; + +namespace Terminal.Gui.Cli; + +/// Markdown-to-ANSI helper for help and viewer output. +public static class MarkdownRenderer +{ + /// Renders markdown as ANSI to and sanitizes rendered output. + public static void RenderToAnsi (string markdown, TextWriter output) + { + ArgumentNullException.ThrowIfNull (markdown); + ArgumentNullException.ThrowIfNull (output); + + string rendered = new Markdown ().RenderToAnsi (markdown, Math.Max (1, Console.IsOutputRedirected ? 80 : Console.WindowWidth)); + output.Write (TerminalEscapeSanitizer.SanitizeRenderedOutput (rendered)); + } +} diff --git a/src/Terminal.Gui.Cli/MetadataHelpProvider.cs b/src/Terminal.Gui.Cli/MetadataHelpProvider.cs new file mode 100644 index 0000000..045286a --- /dev/null +++ b/src/Terminal.Gui.Cli/MetadataHelpProvider.cs @@ -0,0 +1,58 @@ +using System.Text; + +namespace Terminal.Gui.Cli; + +/// Generates help text from registry metadata. +public sealed class MetadataHelpProvider : IHelpProvider +{ + /// + public string? GetRootHelp (ICommandRegistry registry) + { + ArgumentNullException.ThrowIfNull (registry); + + var builder = new StringBuilder (); + builder.AppendLine ("Commands:"); + + foreach (ICliCommand command in registry.All) + { + builder.AppendLine ($" {command.PrimaryAlias}\t{command.Description}"); + } + + builder.AppendLine (); + builder.AppendLine ("Framework options:"); + builder.AppendLine (" --help, -h"); + builder.AppendLine (" --version"); + builder.AppendLine (" --opencli"); + builder.AppendLine (" --json"); + builder.AppendLine (" --initial "); + builder.AppendLine (" --title, --prompt "); + builder.AppendLine (" --timeout "); + builder.AppendLine (" --cat"); + + return builder.ToString (); + } + + /// + public string? GetCommandHelp (ICliCommand command) + { + ArgumentNullException.ThrowIfNull (command); + + var builder = new StringBuilder (); + builder.AppendLine ($"# {command.PrimaryAlias}"); + builder.AppendLine (command.Description); + + if (command.Options.Count > 0) + { + builder.AppendLine (); + builder.AppendLine ("Options:"); + + foreach (CommandOptionDescriptor option in command.Options) + { + string shortName = option.ShortName is null ? string.Empty : $" -{option.ShortName},"; + builder.AppendLine ($" {shortName} --{option.Name}\t{option.Description}"); + } + } + + return builder.ToString (); + } +} diff --git a/src/Terminal.Gui.Cli/OpenCliWriter.cs b/src/Terminal.Gui.Cli/OpenCliWriter.cs new file mode 100644 index 0000000..e9d754d --- /dev/null +++ b/src/Terminal.Gui.Cli/OpenCliWriter.cs @@ -0,0 +1,167 @@ +using System.Text; + +namespace Terminal.Gui.Cli; + +/// Generates an OpenCLI JSON document from registry metadata. +public static class OpenCliWriter +{ + /// Generates OpenCLI JSON for the registered commands and framework options. + public static string Generate (ICommandRegistry registry, CliHostOptions options) + { + ArgumentNullException.ThrowIfNull (registry); + ArgumentNullException.ThrowIfNull (options); + + var builder = new StringBuilder (); + builder.Append ('{'); + AppendProperty (builder, "name", options.ApplicationName); + builder.Append (','); + AppendProperty (builder, "version", options.Version ?? "0.0.0"); + builder.Append (",\"commands\":["); + + bool firstCommand = true; + + foreach (ICliCommand command in registry.All) + { + if (!firstCommand) + { + builder.Append (','); + } + + firstCommand = false; + AppendCommand (builder, command); + } + + builder.Append ("],\"frameworkOptions\":["); + string[] frameworkOptions = ["help", "version", "opencli", "initial", "title", "json", "timeout", "fullscreen", "cat", "output", "rows"]; + + for (int i = 0; i < frameworkOptions.Length; i++) + { + if (i > 0) + { + builder.Append (','); + } + + builder.Append ('\"'); + builder.Append (Escape (frameworkOptions[i])); + builder.Append ('\"'); + } + + builder.Append ("]}"); + return builder.ToString (); + } + + private static void AppendCommand (StringBuilder builder, ICliCommand command) + { + builder.Append ('{'); + AppendProperty (builder, "alias", command.PrimaryAlias); + builder.Append (','); + AppendProperty (builder, "description", command.Description); + builder.Append (','); + AppendProperty (builder, "kind", command.Kind.ToString ().ToLowerInvariant ()); + builder.Append (','); + AppendProperty (builder, "resultType", TypeNames.WireName (command.ResultType)); + builder.Append (",\"aliases\":["); + + for (int i = 0; i < command.Aliases.Count; i++) + { + if (i > 0) + { + builder.Append (','); + } + + builder.Append ('\"'); + builder.Append (Escape (command.Aliases[i])); + builder.Append ('\"'); + } + + builder.Append ("],\"options\":["); + + for (int i = 0; i < command.Options.Count; i++) + { + if (i > 0) + { + builder.Append (','); + } + + AppendOption (builder, command.Options[i]); + } + + builder.Append ("]}"); + } + + private static void AppendOption (StringBuilder builder, CommandOptionDescriptor option) + { + builder.Append ('{'); + AppendProperty (builder, "name", option.Name); + builder.Append (','); + AppendProperty (builder, "description", option.Description); + builder.Append (','); + AppendProperty (builder, "type", TypeNames.WireName (option.ValueType)); + builder.Append (",\"required\":"); + builder.Append (option.Required ? "true" : "false"); + + if (option.ShortName is not null) + { + builder.Append (','); + AppendProperty (builder, "shortName", option.ShortName); + } + + if (option.DefaultValue is not null) + { + builder.Append (','); + AppendProperty (builder, "defaultValue", option.DefaultValue); + } + + builder.Append ('}'); + } + + private static void AppendProperty (StringBuilder builder, string name, string value) + { + builder.Append ('\"'); + builder.Append (Escape (name)); + builder.Append ("\":\""); + builder.Append (Escape (value)); + builder.Append ('\"'); + } + + private static string Escape (string value) + { + var builder = new StringBuilder (value.Length); + + foreach (char c in value) + { + switch (c) + { + case '\\': + builder.Append ("\\\\"); + break; + case '\"': + builder.Append ("\\\""); + break; + case '\n': + builder.Append ("\\n"); + break; + case '\r': + builder.Append ("\\r"); + break; + case '\t': + builder.Append ("\\t"); + break; + default: + if (char.IsControl (c)) + { + builder.Append ("\\u"); + builder.Append (((int)c).ToString ("x4")); + } + else + { + builder.Append (c); + } + + break; + } + } + + return builder.ToString (); + } +} diff --git a/src/Terminal.Gui.Cli/ResultWriter.cs b/src/Terminal.Gui.Cli/ResultWriter.cs new file mode 100644 index 0000000..b4610a5 --- /dev/null +++ b/src/Terminal.Gui.Cli/ResultWriter.cs @@ -0,0 +1,67 @@ +namespace Terminal.Gui.Cli; + +/// Formats command results to stdout, stderr, or an output file. +public static class ResultWriter +{ + /// Writes and returns false when output file creation fails. + public static bool Write (CommandResult result, bool jsonOutput, TextWriter stdout, TextWriter stderr, string? outputPath = null) + { + ArgumentNullException.ThrowIfNull (stdout); + ArgumentNullException.ThrowIfNull (stderr); + + string text = jsonOutput ? ToEnvelope (result).ToJson () : ToPlainText (result); + bool writeToOutput = result.Status is CommandStatus.Ok or CommandStatus.NoResult; + TextWriter writer = result.Status == CommandStatus.Error && !jsonOutput ? stderr : stdout; + + if (writeToOutput && outputPath is not null) + { + try + { + File.WriteAllText (outputPath, text); + } + catch (IOException ex) + { + stderr.WriteLine (ex.Message); + return false; + } + catch (UnauthorizedAccessException ex) + { + stderr.WriteLine (ex.Message); + return false; + } + + return true; + } + + if (text.Length > 0) + { + writer.WriteLine (text); + } + + return true; + } + + private static JsonEnvelope ToEnvelope (CommandResult result) + { + return result.Status switch + { + CommandStatus.Ok => JsonEnvelope.Ok (result.Value), + CommandStatus.Cancelled => JsonEnvelope.Cancelled (), + CommandStatus.NoResult => JsonEnvelope.NoResult (), + CommandStatus.Error => JsonEnvelope.Error (result.ErrorCode ?? "error", result.ErrorMessage ?? "Command failed."), + _ => JsonEnvelope.Error ("error", "Command failed.") + }; + } + + private static string ToPlainText (CommandResult result) + { + return result.Status switch + { + CommandStatus.Ok => result.Value?.ToString () ?? string.Empty, + CommandStatus.Cancelled => "cancelled", + CommandStatus.NoResult => string.Empty, + CommandStatus.Error => result.ErrorMessage ?? result.ErrorCode ?? "Command failed.", + _ => string.Empty + }; + } +} diff --git a/src/Terminal.Gui.Cli/TerminalEscapeSanitizer.cs b/src/Terminal.Gui.Cli/TerminalEscapeSanitizer.cs new file mode 100644 index 0000000..e7738a2 --- /dev/null +++ b/src/Terminal.Gui.Cli/TerminalEscapeSanitizer.cs @@ -0,0 +1,115 @@ +using System.Text; + +namespace Terminal.Gui.Cli; + +/// Strips dangerous terminal escape sequences from untrusted content. +public static class TerminalEscapeSanitizer +{ + /// Sanitizes user-supplied content before it reaches a terminal driver. + public static string? Sanitize (string? input) + { + return input is null ? null : StripEscapes (input, preserveSgr: false); + } + + /// Sanitizes rendered ANSI, preserving only SGR CSI sequences generated by trusted renderers. + public static string SanitizeRenderedOutput (string renderedAnsi) + { + ArgumentNullException.ThrowIfNull (renderedAnsi); + return StripEscapes (renderedAnsi, preserveSgr: true); + } + + private static string StripEscapes (string input, bool preserveSgr) + { + var output = new StringBuilder (input.Length); + + for (int i = 0; i < input.Length; i++) + { + char c = input[i]; + + if (c != '\u001b') + { + output.Append (c); + continue; + } + + if (preserveSgr && TryReadSgr (input, i, out int endIndex)) + { + output.Append (input, i, endIndex - i + 1); + i = endIndex; + continue; + } + + i = SkipEscape (input, i); + } + + return output.ToString (); + } + + private static bool TryReadSgr (string input, int start, out int endIndex) + { + endIndex = start; + + if (start + 2 >= input.Length || input[start + 1] != '[') + { + return false; + } + + int i = start + 2; + + while (i < input.Length && (char.IsDigit (input[i]) || input[i] == ';')) + { + i++; + } + + if (i < input.Length && input[i] == 'm') + { + endIndex = i; + return true; + } + + return false; + } + + private static int SkipEscape (string input, int start) + { + if (start + 1 >= input.Length) + { + return start; + } + + char introducer = input[start + 1]; + + if (introducer == '[') + { + for (int i = start + 2; i < input.Length; i++) + { + if (input[i] is >= '@' and <= '~') + { + return i; + } + } + + return input.Length - 1; + } + + if (introducer == ']') + { + for (int i = start + 2; i < input.Length; i++) + { + if (input[i] == '\u0007') + { + return i; + } + + if (input[i] == '\u001b' && i + 1 < input.Length && input[i + 1] == '\\') + { + return i + 1; + } + } + + return input.Length - 1; + } + + return start + 1; + } +} diff --git a/src/Terminal.Gui.Cli/TypeNames.cs b/src/Terminal.Gui.Cli/TypeNames.cs new file mode 100644 index 0000000..f2554cf --- /dev/null +++ b/src/Terminal.Gui.Cli/TypeNames.cs @@ -0,0 +1,50 @@ +namespace Terminal.Gui.Cli; + +/// Maps CLR types to stable wire-format type names. +public static class TypeNames +{ + /// Returns the wire-format name for . + public static string WireName (Type type) + { + ArgumentNullException.ThrowIfNull (type); + + Type nullableType = Nullable.GetUnderlyingType (type) ?? type; + + if (nullableType == typeof (void)) + { + return "void"; + } + + if (nullableType == typeof (string)) + { + return "string"; + } + + if (nullableType == typeof (bool)) + { + return "boolean"; + } + + if (nullableType == typeof (int) || nullableType == typeof (long) || nullableType == typeof (short) || nullableType == typeof (byte)) + { + return "integer"; + } + + if (nullableType == typeof (double) || nullableType == typeof (float) || nullableType == typeof (decimal)) + { + return "number"; + } + + if (nullableType == typeof (DateTime) || nullableType == typeof (DateTimeOffset)) + { + return "datetime"; + } + + if (nullableType.IsEnum) + { + return "string"; + } + + return "object"; + } +} diff --git a/tests/Terminal.Gui.Cli.Tests/ArgParserTests.cs b/tests/Terminal.Gui.Cli.Tests/ArgParserTests.cs new file mode 100644 index 0000000..60b62cd --- /dev/null +++ b/tests/Terminal.Gui.Cli.Tests/ArgParserTests.cs @@ -0,0 +1,66 @@ +using Terminal.Gui.App; +using Xunit; + +namespace Terminal.Gui.Cli.Tests; + +public sealed class ArgParserTests +{ + [Fact] + public void Parse_SeparatesFrameworkGlobalsCommandOptionsAndPositionals () + { + var parser = new ArgParser ([new GlobalOptionDescriptor ("profile", "P", "Profile", false, true)]); + var command = new TestCommand (acceptsPositionalArgs: true); + + ArgParser.ParseResult result = parser.Parse (["--profile", "dev", "pick", "--json", "--name", "value", "arg"], command); + + Assert.True (result.Success, result.Error); + Assert.Equal ("pick", result.Alias); + Assert.NotNull (result.Options); + Assert.True (result.Options.JsonOutput); + Assert.Equal ("value", result.Options.CommandOptions["name"]); + Assert.Equal (["dev"], result.Options.GetExtensionList ("profile")); + Assert.Equal (["arg"], result.Options.Arguments); + } + + [Theory] + [InlineData ("150ms", 150)] + [InlineData ("2s", 2000)] + public void TryParseTimeout_AcceptsSupportedSuffixes (string input, int milliseconds) + { + Assert.True (ArgParser.TryParseTimeout (input, out TimeSpan timeout)); + Assert.Equal (milliseconds, (int)timeout.TotalMilliseconds); + } + + [Fact] + public void Parse_RejectsMissingRequiredCommandOption () + { + var parser = new ArgParser ([]); + + ArgParser.ParseResult result = parser.Parse (["pick"], new TestCommand (acceptsPositionalArgs: false)); + + Assert.False (result.Success); + Assert.Contains ("--name", result.Error); + } + + private sealed class TestCommand (bool acceptsPositionalArgs) : ICliCommand + { + public string PrimaryAlias => "pick"; + + public IReadOnlyList Aliases { get; } = ["pick"]; + + public string Description => "Test command."; + + public CommandKind Kind => CommandKind.Input; + + public Type ResultType => typeof (string); + + public IReadOnlyList Options { get; } = [new ("name", "n", typeof (string), "Name", true, null)]; + + public bool AcceptsPositionalArgs { get; } = acceptsPositionalArgs; + + public Task RunAsync (IApplication app, string? initial, CommandRunOptions options, CancellationToken cancellationToken) + { + return Task.FromResult (new CommandResult (CommandStatus.Ok, "ok", null, null)); + } + } +} diff --git a/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs b/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs new file mode 100644 index 0000000..4a5b11a --- /dev/null +++ b/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs @@ -0,0 +1,43 @@ +using Xunit; + +namespace Terminal.Gui.Cli.Tests; + +public sealed class CliHostTests +{ + [Fact] + public async Task RunAsync_OpenCli_WritesRegisteredBuiltIns () + { + var host = new CliHost (options => + { + options.ApplicationName = "sample"; + options.Version = "1.2.3"; + }); + using var stdout = new StringWriter (); + using var stderr = new StringWriter (); + + int exitCode = await host.RunAsync (["--opencli"], TestContext.Current.CancellationToken, stdout, stderr); + + Assert.Equal (ExitCodes.Ok, exitCode); + Assert.Contains ("\"name\":\"sample\"", stdout.ToString ()); + Assert.Contains ("\"alias\":\"help\"", stdout.ToString ()); + Assert.Equal (string.Empty, stderr.ToString ()); + } + + [Fact] + public async Task RunAsync_AgentGuideCat_WritesLiteralWithoutStartingTui () + { + var host = new CliHost (options => + { + options.AgentGuide = "# Guide"; + options.AgentGuideIsResource = false; + }); + using var stdout = new StringWriter (); + using var stderr = new StringWriter (); + + int exitCode = await host.RunAsync (["agent-guide", "--cat"], TestContext.Current.CancellationToken, stdout, stderr); + + Assert.Equal (ExitCodes.Ok, exitCode); + Assert.Equal ("# Guide", stdout.ToString ()); + Assert.Equal (string.Empty, stderr.ToString ()); + } +} diff --git a/tests/Terminal.Gui.Cli.Tests/CommandRegistryTests.cs b/tests/Terminal.Gui.Cli.Tests/CommandRegistryTests.cs new file mode 100644 index 0000000..be19c4f --- /dev/null +++ b/tests/Terminal.Gui.Cli.Tests/CommandRegistryTests.cs @@ -0,0 +1,48 @@ +using Terminal.Gui.App; +using Xunit; + +namespace Terminal.Gui.Cli.Tests; + +public sealed class CommandRegistryTests +{ + [Fact] + public void Register_ResolvesAliasesCaseInsensitively () + { + var registry = new CommandRegistry (); + var command = new TestCommand ("pick", ["pick", "select"]); + + registry.Register (command); + + Assert.True (registry.TryResolve ("SELECT", out ICliCommand? resolved)); + Assert.Same (command, resolved); + } + + [Fact] + public void Register_RejectsDuplicateAliasesCaseInsensitively () + { + var registry = new CommandRegistry (); + registry.Register (new TestCommand ("pick", ["pick"])); + + Assert.Throws (() => registry.Register (new TestCommand ("other", ["PICK", "other"]))); + } + + private sealed class TestCommand (string primaryAlias, IReadOnlyList aliases) : ICliCommand + { + public string PrimaryAlias { get; } = primaryAlias; + + public IReadOnlyList Aliases { get; } = aliases; + + public string Description => "Test command."; + + public CommandKind Kind => CommandKind.Input; + + public Type ResultType => typeof (string); + + public IReadOnlyList Options { get; } = []; + + public Task RunAsync (IApplication app, string? initial, CommandRunOptions options, CancellationToken cancellationToken) + { + return Task.FromResult (new CommandResult (CommandStatus.Ok, "ok", null, null)); + } + } +} diff --git a/tests/Terminal.Gui.Cli.Tests/OutputTests.cs b/tests/Terminal.Gui.Cli.Tests/OutputTests.cs new file mode 100644 index 0000000..be629ea --- /dev/null +++ b/tests/Terminal.Gui.Cli.Tests/OutputTests.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using Xunit; + +namespace Terminal.Gui.Cli.Tests; + +public sealed class OutputTests +{ + [Fact] + public void JsonEnvelope_ToJson_UsesCamelCaseAndOmitsNulls () + { + string json = JsonEnvelope.Ok ("value").ToJson (); + + using JsonDocument document = JsonDocument.Parse (json); + Assert.Equal (1, document.RootElement.GetProperty ("schemaVersion").GetInt32 ()); + Assert.Equal ("ok", document.RootElement.GetProperty ("status").GetString ()); + Assert.Equal ("value", document.RootElement.GetProperty ("value").GetString ()); + Assert.False (document.RootElement.TryGetProperty ("code", out _)); + } + + [Fact] + public void ResultWriter_WritesErrorsToStderrInPlainText () + { + using var stdout = new StringWriter (); + using var stderr = new StringWriter (); + + bool success = ResultWriter.Write (new CommandResult (CommandStatus.Error, null, "validation", "bad"), false, stdout, stderr); + + Assert.True (success); + Assert.Equal (string.Empty, stdout.ToString ()); + Assert.Contains ("bad", stderr.ToString ()); + } + + [Fact] + public void TerminalEscapeSanitizer_RemovesOscAndPreservesRenderedSgr () + { + Assert.Equal ("title", TerminalEscapeSanitizer.Sanitize ("\u001b]0;bad\u0007title")); + Assert.Equal ("\u001b[1mstrong\u001b[0m", TerminalEscapeSanitizer.SanitizeRenderedOutput ("\u001b[1mstrong\u001b[0m")); + } +} From d0831a5de53d7aaaaccb50b1cd082a951cb5e928 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 23:35:54 +0000 Subject: [PATCH 03/11] Fix CliHost formatting Agent-Logs-Url: https://github.com/gui-cs/cli/sessions/90f515f0-dbc5-4dc5-b941-ecd32f33b83f Co-authored-by: tig <585482+tig@users.noreply.github.com> --- src/Terminal.Gui.Cli/CliHost.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Terminal.Gui.Cli/CliHost.cs b/src/Terminal.Gui.Cli/CliHost.cs index 61a7101..5c84e79 100644 --- a/src/Terminal.Gui.Cli/CliHost.cs +++ b/src/Terminal.Gui.Cli/CliHost.cs @@ -140,7 +140,7 @@ private void RegisterBuiltIns () } else if (_options.AgentGuide is not null) { - Registry.Register (new AgentGuideCommand (ResolveAgentGuide ())) ; + Registry.Register (new AgentGuideCommand (ResolveAgentGuide ())); } } From 1da42b168ae467e5ec1040e2a78107aa2a919fbb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 02:49:37 +0000 Subject: [PATCH 04/11] Address cancellation and timeout review feedback Agent-Logs-Url: https://github.com/gui-cs/cli/sessions/65752f23-da62-481d-898c-b455cb3e3afa Co-authored-by: tig <585482+tig@users.noreply.github.com> --- src/Terminal.Gui.Cli/ArgParser.cs | 26 ++++++++---- src/Terminal.Gui.Cli/CliHost.cs | 22 +++++++++- .../Terminal.Gui.Cli.Tests/ArgParserTests.cs | 6 +++ tests/Terminal.Gui.Cli.Tests/CliHostTests.cs | 41 +++++++++++++++++++ 4 files changed, 85 insertions(+), 10 deletions(-) diff --git a/src/Terminal.Gui.Cli/ArgParser.cs b/src/Terminal.Gui.Cli/ArgParser.cs index c0be1ab..c821c9c 100644 --- a/src/Terminal.Gui.Cli/ArgParser.cs +++ b/src/Terminal.Gui.Cli/ArgParser.cs @@ -176,19 +176,29 @@ public static bool TryParseTimeout (string input, out TimeSpan timeout) string suffix = input.EndsWith ("ms", StringComparison.OrdinalIgnoreCase) ? "ms" : input[^1..].ToLowerInvariant (); string numberText = suffix == "ms" ? input[..^2] : input[..^1]; - if (!double.TryParse (numberText, NumberStyles.Float, CultureInfo.InvariantCulture, out double value) || value < 0) + if (!double.TryParse (numberText, NumberStyles.Float, CultureInfo.InvariantCulture, out double value) + || !double.IsFinite (value) + || value < 0) { return false; } - timeout = suffix switch + try { - "ms" => TimeSpan.FromMilliseconds (value), - "s" => TimeSpan.FromSeconds (value), - "m" => TimeSpan.FromMinutes (value), - "h" => TimeSpan.FromHours (value), - _ => default - }; + timeout = suffix switch + { + "ms" => TimeSpan.FromMilliseconds (value), + "s" => TimeSpan.FromSeconds (value), + "m" => TimeSpan.FromMinutes (value), + "h" => TimeSpan.FromHours (value), + _ => default + }; + } + catch (OverflowException) + { + timeout = default; + return false; + } return timeout != default || value == 0; } diff --git a/src/Terminal.Gui.Cli/CliHost.cs b/src/Terminal.Gui.Cli/CliHost.cs index 5c84e79..5ed6aff 100644 --- a/src/Terminal.Gui.Cli/CliHost.cs +++ b/src/Terminal.Gui.Cli/CliHost.cs @@ -83,7 +83,16 @@ public async Task RunAsync ( if (command is IViewerCommand viewer && runOptions.Cat) { - CommandResult? catResult = await viewer.RenderCatAsync (runOptions, stdout, effectiveToken); + CommandResult? catResult; + + try + { + catResult = await viewer.RenderCatAsync (runOptions, stdout, effectiveToken); + } + catch (OperationCanceledException) + { + return ExitCodes.Cancelled; + } if (catResult is not null) { @@ -91,7 +100,16 @@ public async Task RunAsync ( } } - CommandResult result = await RunWithTerminalGuiAsync (command, runOptions, effectiveToken); + CommandResult result; + + try + { + result = await RunWithTerminalGuiAsync (command, runOptions, effectiveToken); + } + catch (OperationCanceledException) + { + result = new CommandResult (CommandStatus.Cancelled, null, null, null); + } if (!ResultWriter.Write (result, runOptions.JsonOutput, stdout, stderr, runOptions.OutputPath)) { diff --git a/tests/Terminal.Gui.Cli.Tests/ArgParserTests.cs b/tests/Terminal.Gui.Cli.Tests/ArgParserTests.cs index 60b62cd..dfce171 100644 --- a/tests/Terminal.Gui.Cli.Tests/ArgParserTests.cs +++ b/tests/Terminal.Gui.Cli.Tests/ArgParserTests.cs @@ -31,6 +31,12 @@ public void TryParseTimeout_AcceptsSupportedSuffixes (string input, int millisec Assert.Equal (milliseconds, (int)timeout.TotalMilliseconds); } + [Fact] + public void TryParseTimeout_RejectsOverflowingValues () + { + Assert.False (ArgParser.TryParseTimeout ("1e999999h", out _)); + } + [Fact] public void Parse_RejectsMissingRequiredCommandOption () { diff --git a/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs b/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs index 4a5b11a..c8c4637 100644 --- a/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs +++ b/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs @@ -1,4 +1,5 @@ using Xunit; +using Terminal.Gui.App; namespace Terminal.Gui.Cli.Tests; @@ -40,4 +41,44 @@ public async Task RunAsync_AgentGuideCat_WritesLiteralWithoutStartingTui () Assert.Equal ("# Guide", stdout.ToString ()); Assert.Equal (string.Empty, stderr.ToString ()); } + + [Fact] + public async Task RunAsync_CommandCancellation_ReturnsCancelledExitCode () + { + var host = new CliHost (); + host.Registry.Register (new CancellingCatCommand ()); + using var stdout = new StringWriter (); + using var stderr = new StringWriter (); + + int exitCode = await host.RunAsync (["cancel", "--cat"], TestContext.Current.CancellationToken, stdout, stderr); + + Assert.Equal (ExitCodes.Cancelled, exitCode); + Assert.Equal (string.Empty, stdout.ToString ()); + Assert.Equal (string.Empty, stderr.ToString ()); + } + + private sealed class CancellingCatCommand : IViewerCommand + { + public string PrimaryAlias => "cancel"; + + public IReadOnlyList Aliases { get; } = ["cancel"]; + + public string Description => "Cancels."; + + public CommandKind Kind => CommandKind.Viewer; + + public Type ResultType => typeof (void); + + public IReadOnlyList Options { get; } = []; + + public Task RunAsync (IApplication app, string? initial, CommandRunOptions options, CancellationToken cancellationToken) + { + throw new OperationCanceledException (cancellationToken); + } + + public Task RenderCatAsync (CommandRunOptions options, TextWriter stdout, CancellationToken cancellationToken) + { + throw new OperationCanceledException (cancellationToken); + } + } } From 6f2997c033b5979d6b4be59755b65bdea30b5c81 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 02:51:26 +0000 Subject: [PATCH 05/11] Rename timeout overflow regression test Agent-Logs-Url: https://github.com/gui-cs/cli/sessions/65752f23-da62-481d-898c-b455cb3e3afa Co-authored-by: tig <585482+tig@users.noreply.github.com> --- tests/Terminal.Gui.Cli.Tests/ArgParserTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Terminal.Gui.Cli.Tests/ArgParserTests.cs b/tests/Terminal.Gui.Cli.Tests/ArgParserTests.cs index dfce171..36e53db 100644 --- a/tests/Terminal.Gui.Cli.Tests/ArgParserTests.cs +++ b/tests/Terminal.Gui.Cli.Tests/ArgParserTests.cs @@ -32,7 +32,7 @@ public void TryParseTimeout_AcceptsSupportedSuffixes (string input, int millisec } [Fact] - public void TryParseTimeout_RejectsOverflowingValues () + public void TryParseTimeout_WithOverflowValue_ReturnsFalse () { Assert.False (ArgParser.TryParseTimeout ("1e999999h", out _)); } From 534892654b1c63948dccaa5cfddedbab661ecdaf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 02:53:04 +0000 Subject: [PATCH 06/11] Clean up cancellation regression test Agent-Logs-Url: https://github.com/gui-cs/cli/sessions/65752f23-da62-481d-898c-b455cb3e3afa Co-authored-by: tig <585482+tig@users.noreply.github.com> --- src/Terminal.Gui.Cli/CliHost.cs | 6 +++++- tests/Terminal.Gui.Cli.Tests/CliHostTests.cs | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Terminal.Gui.Cli/CliHost.cs b/src/Terminal.Gui.Cli/CliHost.cs index 5ed6aff..d1125e6 100644 --- a/src/Terminal.Gui.Cli/CliHost.cs +++ b/src/Terminal.Gui.Cli/CliHost.cs @@ -108,7 +108,11 @@ public async Task RunAsync ( } catch (OperationCanceledException) { - result = new CommandResult (CommandStatus.Cancelled, null, null, null); + result = new CommandResult ( + Status: CommandStatus.Cancelled, + Value: null, + ErrorCode: null, + ErrorMessage: null); } if (!ResultWriter.Write (result, runOptions.JsonOutput, stdout, stderr, runOptions.OutputPath)) diff --git a/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs b/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs index c8c4637..9c9cb5f 100644 --- a/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs +++ b/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs @@ -49,8 +49,10 @@ public async Task RunAsync_CommandCancellation_ReturnsCancelledExitCode () host.Registry.Register (new CancellingCatCommand ()); using var stdout = new StringWriter (); using var stderr = new StringWriter (); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource (TestContext.Current.CancellationToken); + cancellation.Cancel (); - int exitCode = await host.RunAsync (["cancel", "--cat"], TestContext.Current.CancellationToken, stdout, stderr); + int exitCode = await host.RunAsync (["cancel", "--cat"], cancellation.Token, stdout, stderr); Assert.Equal (ExitCodes.Cancelled, exitCode); Assert.Equal (string.Empty, stdout.ToString ()); From 1a3ed7fa1e44b1921808bb306bdc5ad65df8447c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 02:54:40 +0000 Subject: [PATCH 07/11] Refine cancellation handling cleanup Agent-Logs-Url: https://github.com/gui-cs/cli/sessions/65752f23-da62-481d-898c-b455cb3e3afa Co-authored-by: tig <585482+tig@users.noreply.github.com> --- src/Terminal.Gui.Cli/CliHost.cs | 15 ++++++++++----- tests/Terminal.Gui.Cli.Tests/CliHostTests.cs | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/Terminal.Gui.Cli/CliHost.cs b/src/Terminal.Gui.Cli/CliHost.cs index d1125e6..af09902 100644 --- a/src/Terminal.Gui.Cli/CliHost.cs +++ b/src/Terminal.Gui.Cli/CliHost.cs @@ -108,11 +108,7 @@ public async Task RunAsync ( } catch (OperationCanceledException) { - result = new CommandResult ( - Status: CommandStatus.Cancelled, - Value: null, - ErrorCode: null, - ErrorMessage: null); + result = CreateCancelledResult (); } if (!ResultWriter.Write (result, runOptions.JsonOutput, stdout, stderr, runOptions.OutputPath)) @@ -123,6 +119,15 @@ public async Task RunAsync ( return ExitCodes.FromResult (result); } + private static CommandResult CreateCancelledResult () + { + return new CommandResult ( + Status: CommandStatus.Cancelled, + Value: null, + ErrorCode: null, + ErrorMessage: null); + } + private async Task RunWithTerminalGuiAsync (ICliCommand command, CommandRunOptions runOptions, CancellationToken cancellationToken) { using IApplication app = Application.Create ().Init (); diff --git a/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs b/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs index 9c9cb5f..4d31eed 100644 --- a/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs +++ b/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs @@ -49,7 +49,7 @@ public async Task RunAsync_CommandCancellation_ReturnsCancelledExitCode () host.Registry.Register (new CancellingCatCommand ()); using var stdout = new StringWriter (); using var stderr = new StringWriter (); - using var cancellation = CancellationTokenSource.CreateLinkedTokenSource (TestContext.Current.CancellationToken); + using var cancellation = new CancellationTokenSource (); cancellation.Cancel (); int exitCode = await host.RunAsync (["cancel", "--cat"], cancellation.Token, stdout, stderr); From 73eaae990d1139b65d3e14e788e650422c8611a0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 24 May 2026 13:14:21 +0000 Subject: [PATCH 08/11] Apply ReSharper cleanup and enforce style checks Agent-Logs-Url: https://github.com/gui-cs/cli/sessions/9e373df4-deb3-46a7-963e-8161efc70256 Co-authored-by: tig <585482+tig@users.noreply.github.com> --- .github/workflows/ci.yml | 10 ++- .github/workflows/release.yml | 12 +++ dotnet-tools.json | 13 +++ src/Terminal.Gui.Cli/AgentGuideCommand.cs | 6 +- src/Terminal.Gui.Cli/ArgParser.cs | 79 ++++++++++--------- src/Terminal.Gui.Cli/CliHost.cs | 21 ++--- src/Terminal.Gui.Cli/CommandRegistry.cs | 6 +- .../EmbeddedMarkdownHelpProvider.cs | 2 +- src/Terminal.Gui.Cli/HelpCommand.cs | 19 +++-- src/Terminal.Gui.Cli/ICliCommand.cs | 9 ++- src/Terminal.Gui.Cli/ICliCommandGeneric.cs | 16 ++-- src/Terminal.Gui.Cli/ICommandRegistry.cs | 8 +- src/Terminal.Gui.Cli/IViewerCommand.cs | 13 +-- src/Terminal.Gui.Cli/MarkdownRenderer.cs | 3 +- src/Terminal.Gui.Cli/MetadataHelpProvider.cs | 6 +- src/Terminal.Gui.Cli/OpenCliWriter.cs | 19 +++-- src/Terminal.Gui.Cli/ResultWriter.cs | 10 ++- .../TerminalEscapeSanitizer.cs | 20 ++--- src/Terminal.Gui.Cli/TypeNames.cs | 3 +- .../Terminal.Gui.Cli.Tests/ArgParserTests.cs | 17 ++-- tests/Terminal.Gui.Cli.Tests/CliHostTests.cs | 35 ++++---- .../CommandRegistryTests.cs | 12 +-- tests/Terminal.Gui.Cli.Tests/OutputTests.cs | 12 +-- 23 files changed, 212 insertions(+), 139 deletions(-) create mode 100644 dotnet-tools.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd0aac0..b4e8b44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,9 +32,17 @@ jobs: - name: Build run: dotnet build Terminal.Gui.Cli.slnx --no-restore -c Debug + - name: Restore .NET tools + if: matrix.os == 'ubuntu-latest' + run: dotnet tool restore + - name: Verify code style if: matrix.os == 'ubuntu-latest' - run: dotnet format Terminal.Gui.Cli.slnx --no-restore --verify-no-changes + shell: bash + run: | + dotnet jb cleanupcode Terminal.Gui.Cli.slnx --no-build --verbosity=WARN + dotnet format Terminal.Gui.Cli.slnx --no-restore + git diff --exit-code - name: Terminal.Gui.Cli.Tests run: dotnet run --project tests/Terminal.Gui.Cli.Tests --no-build -c Debug diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b78ecc..41eed60 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,6 +69,18 @@ jobs: - name: Build run: dotnet build Terminal.Gui.Cli.slnx --no-restore -c Release -p:Version=${{ env.VERSION }} + - name: Restore .NET tools + if: matrix.os == 'ubuntu-latest' + run: dotnet tool restore + + - name: Verify code style + if: matrix.os == 'ubuntu-latest' + shell: bash + run: | + dotnet jb cleanupcode Terminal.Gui.Cli.slnx --no-build --verbosity=WARN + dotnet format Terminal.Gui.Cli.slnx --no-restore + git diff --exit-code + - name: Terminal.Gui.Cli.Tests run: dotnet run --project tests/Terminal.Gui.Cli.Tests --no-build -c Release diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..7fd26cd --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "jetbrains.resharper.globaltools": { + "version": "2026.1.2", + "commands": [ + "jb" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/src/Terminal.Gui.Cli/AgentGuideCommand.cs b/src/Terminal.Gui.Cli/AgentGuideCommand.cs index 673a17b..533c50a 100644 --- a/src/Terminal.Gui.Cli/AgentGuideCommand.cs +++ b/src/Terminal.Gui.Cli/AgentGuideCommand.cs @@ -32,13 +32,15 @@ public AgentGuideCommand (string markdown) public IReadOnlyList Options { get; } = []; /// - public Task RunAsync (IApplication app, string? initial, CommandRunOptions options, CancellationToken cancellationToken) + public Task RunAsync (IApplication app, string? initial, CommandRunOptions options, + CancellationToken cancellationToken) { return Task.FromResult (new CommandResult (CommandStatus.Ok, _markdown, null, null)); } /// - public Task RenderCatAsync (CommandRunOptions options, TextWriter stdout, CancellationToken cancellationToken) + public Task RenderCatAsync (CommandRunOptions options, TextWriter stdout, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull (stdout); stdout.Write (_markdown); diff --git a/src/Terminal.Gui.Cli/ArgParser.cs b/src/Terminal.Gui.Cli/ArgParser.cs index c821c9c..f4539d4 100644 --- a/src/Terminal.Gui.Cli/ArgParser.cs +++ b/src/Terminal.Gui.Cli/ArgParser.cs @@ -5,6 +5,19 @@ namespace Terminal.Gui.Cli; /// Data-driven parser for framework flags, consumer globals, and per-command options. public sealed class ArgParser { + /// Root flags that exit without command dispatch. + public enum RootFlag + { + /// Root --help or -h. + Help, + + /// Root --version. + Version, + + /// Root --opencli. + OpenCli + } + private readonly IReadOnlyList _globalOptions; private readonly int _maxInitialChars; @@ -25,7 +38,7 @@ public ParseResult Parse (string[] args, ICliCommand? command = null) return new ParseResult { Success = true, RootFlag = RootFlag.Help }; } - int index = 0; + var index = 0; if (IsRootFlag (args[0], out RootFlag rootFlag)) { @@ -37,22 +50,22 @@ public ParseResult Parse (string[] args, ICliCommand? command = null) return new ParseResult { Success = true, RootFlag = rootFlag }; } - var commandOptions = new Dictionary (StringComparer.OrdinalIgnoreCase); - var extensionValues = new Dictionary> (StringComparer.OrdinalIgnoreCase); - var arguments = new List (); + Dictionary commandOptions = new (StringComparer.OrdinalIgnoreCase); + Dictionary> extensionValues = new (StringComparer.OrdinalIgnoreCase); + List arguments = new (); string? alias = null; string? initial = null; string? title = null; - bool json = false; + var json = false; TimeSpan? timeout = null; - bool fullscreen = false; - bool cat = false; + var fullscreen = false; + var cat = false; string? outputPath = null; int? rows = null; while (index < args.Length) { - string token = args[index]; + var token = args[index]; if (alias is null && !token.StartsWith ('-')) { @@ -68,7 +81,8 @@ public ParseResult Parse (string[] args, ICliCommand? command = null) continue; } - if (TryParseFrameworkOption (args, ref index, token, ref initial, ref title, ref json, ref timeout, ref fullscreen, ref cat, ref outputPath, ref rows, out string? frameworkError)) + if (TryParseFrameworkOption (args, ref index, token, ref initial, ref title, ref json, ref timeout, + ref fullscreen, ref cat, ref outputPath, ref rows, out var frameworkError)) { if (frameworkError is not null) { @@ -80,7 +94,8 @@ public ParseResult Parse (string[] args, ICliCommand? command = null) if (TryFindGlobalOption (token, out GlobalOptionDescriptor? globalOption)) { - if (!AddOptionValue (args, ref index, token, globalOption!.Name, globalOption.IsFlag, globalOption.Repeatable, extensionValues, out string? extensionError)) + if (!AddOptionValue (args, ref index, token, globalOption!.Name, globalOption.IsFlag, + globalOption.Repeatable, extensionValues, out var extensionError)) { return ParseResult.Fail (extensionError ?? $"Invalid option '{token}'."); } @@ -88,11 +103,13 @@ public ParseResult Parse (string[] args, ICliCommand? command = null) continue; } - if (command is not null && TryFindCommandOption (command, token, out CommandOptionDescriptor? commandOption)) + if (command is not null && + TryFindCommandOption (command, token, out CommandOptionDescriptor? commandOption)) { - bool isFlag = commandOption!.ValueType == typeof (bool); + var isFlag = commandOption!.ValueType == typeof (bool); - if (!AddCommandOptionValue (args, ref index, token, commandOption.Name, isFlag, commandOptions, out string? commandError)) + if (!AddCommandOptionValue (args, ref index, token, commandOption.Name, isFlag, commandOptions, + out var commandError)) { return ParseResult.Fail (commandError ?? $"Invalid option '{token}'."); } @@ -134,12 +151,12 @@ public ParseResult Parse (string[] args, ICliCommand? command = null) } } - var extensions = extensionValues.ToDictionary ( + Dictionary> extensions = extensionValues.ToDictionary ( static pair => pair.Key, static pair => (IReadOnlyList)pair.Value, StringComparer.OrdinalIgnoreCase); - var options = new CommandRunOptions + CommandRunOptions options = new () { Initial = initial, Title = title, @@ -173,10 +190,10 @@ public static bool TryParseTimeout (string input, out TimeSpan timeout) return false; } - string suffix = input.EndsWith ("ms", StringComparison.OrdinalIgnoreCase) ? "ms" : input[^1..].ToLowerInvariant (); - string numberText = suffix == "ms" ? input[..^2] : input[..^1]; + var suffix = input.EndsWith ("ms", StringComparison.OrdinalIgnoreCase) ? "ms" : input[^1..].ToLowerInvariant (); + var numberText = suffix == "ms" ? input[..^2] : input[..^1]; - if (!double.TryParse (numberText, NumberStyles.Float, CultureInfo.InvariantCulture, out double value) + if (!double.TryParse (numberText, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) || !double.IsFinite (value) || value < 0) { @@ -243,7 +260,7 @@ private static bool TryParseFrameworkOption ( index++; return true; case "--timeout": - if (!ReadValue (args, ref index, token, out string? timeoutText, out error)) + if (!ReadValue (args, ref index, token, out var timeoutText, out error)) { return true; } @@ -267,12 +284,13 @@ private static bool TryParseFrameworkOption ( case "--output" or "-o": return ReadValue (args, ref index, token, out outputPath, out error); case "--rows": - if (!ReadValue (args, ref index, token, out string? rowsText, out error)) + if (!ReadValue (args, ref index, token, out var rowsText, out error)) { return true; } - if (!int.TryParse (rowsText, NumberStyles.None, CultureInfo.InvariantCulture, out int parsedRows) || parsedRows <= 0) + if (!int.TryParse (rowsText, NumberStyles.None, CultureInfo.InvariantCulture, out var parsedRows) || + parsedRows <= 0) { error = $"Invalid rows value '{rowsText}'."; return true; @@ -287,13 +305,15 @@ private static bool TryParseFrameworkOption ( private bool TryFindGlobalOption (string token, out GlobalOptionDescriptor? option) { - option = _globalOptions.FirstOrDefault (candidate => MatchesOption (token, candidate.Name, candidate.ShortName)); + option = _globalOptions.FirstOrDefault (candidate => + MatchesOption (token, candidate.Name, candidate.ShortName)); return option is not null; } private static bool TryFindCommandOption (ICliCommand command, string token, out CommandOptionDescriptor? option) { - option = command.Options.FirstOrDefault (candidate => MatchesOption (token, candidate.Name, candidate.ShortName)); + option = + command.Options.FirstOrDefault (candidate => MatchesOption (token, candidate.Name, candidate.ShortName)); return option is not null; } @@ -419,17 +439,4 @@ public static ParseResult Fail (string error) return new ParseResult { Success = false, Error = error }; } } - - /// Root flags that exit without command dispatch. - public enum RootFlag - { - /// Root --help or -h. - Help, - - /// Root --version. - Version, - - /// Root --opencli. - OpenCli - } } diff --git a/src/Terminal.Gui.Cli/CliHost.cs b/src/Terminal.Gui.Cli/CliHost.cs index af09902..018afe0 100644 --- a/src/Terminal.Gui.Cli/CliHost.cs +++ b/src/Terminal.Gui.Cli/CliHost.cs @@ -6,9 +6,9 @@ namespace Terminal.Gui.Cli; /// The main entry point. Owns parsing, dispatch, Terminal.Gui lifecycle, and output. public sealed class CliHost { + private readonly IHelpProvider _helpProvider; private readonly CliHostOptions _options; private readonly ArgParser _parser; - private readonly IHelpProvider _helpProvider; /// Creates a host, applies configuration, creates its registry, and registers built-ins. public CliHost (Action? configure = null) @@ -48,7 +48,8 @@ public async Task RunAsync ( return ExitCodes.Ok; } - if (initialParse.Alias is null || !Registry.TryResolve (initialParse.Alias, out ICliCommand? command) || command is null) + if (initialParse.Alias is null || !Registry.TryResolve (initialParse.Alias, out ICliCommand? command) || + command is null) { stderr.WriteLine ($"Unknown command '{initialParse.Alias}'."); return ExitCodes.UsageError; @@ -122,13 +123,14 @@ public async Task RunAsync ( private static CommandResult CreateCancelledResult () { return new CommandResult ( - Status: CommandStatus.Cancelled, - Value: null, - ErrorCode: null, - ErrorMessage: null); + CommandStatus.Cancelled, + null, + null, + null); } - private async Task RunWithTerminalGuiAsync (ICliCommand command, CommandRunOptions runOptions, CancellationToken cancellationToken) + private async Task RunWithTerminalGuiAsync (ICliCommand command, CommandRunOptions runOptions, + CancellationToken cancellationToken) { using IApplication app = Application.Create ().Init (); return await command.RunAsync (app, runOptions.Initial, runOptions, cancellationToken); @@ -139,7 +141,8 @@ private void WriteRootFlag (ArgParser.RootFlag rootFlag, TextWriter stdout) switch (rootFlag) { case ArgParser.RootFlag.Help: - stdout.WriteLine (_helpProvider.GetRootHelp (Registry) ?? new MetadataHelpProvider ().GetRootHelp (Registry)); + stdout.WriteLine (_helpProvider.GetRootHelp (Registry) ?? + new MetadataHelpProvider ().GetRootHelp (Registry)); break; case ArgParser.RootFlag.Version: stdout.WriteLine ($"{_options.ApplicationName} {_options.Version ?? "0.0.0"}"); @@ -188,7 +191,7 @@ private string ResolveAgentGuide () throw new InvalidOperationException ($"AgentGuide resource '{_options.AgentGuide}' was not found."); } - using var reader = new StreamReader (stream); + using StreamReader reader = new (stream); return reader.ReadToEnd (); } } diff --git a/src/Terminal.Gui.Cli/CommandRegistry.cs b/src/Terminal.Gui.Cli/CommandRegistry.cs index 92b1321..a347336 100644 --- a/src/Terminal.Gui.Cli/CommandRegistry.cs +++ b/src/Terminal.Gui.Cli/CommandRegistry.cs @@ -3,8 +3,8 @@ namespace Terminal.Gui.Cli; /// Default case-insensitive, duplicate-rejecting command registry. public sealed class CommandRegistry : ICommandRegistry { - private readonly Dictionary _commandsByAlias = new (StringComparer.OrdinalIgnoreCase); private readonly List _commands = []; + private readonly Dictionary _commandsByAlias = new (StringComparer.OrdinalIgnoreCase); /// public IReadOnlyCollection All => _commands; @@ -19,7 +19,7 @@ public void Register (ICliCommand command) throw new InvalidOperationException ("PrimaryAlias must be present in Aliases."); } - foreach (string alias in command.Aliases) + foreach (var alias in command.Aliases) { if (string.IsNullOrWhiteSpace (alias)) { @@ -34,7 +34,7 @@ public void Register (ICliCommand command) _commands.Add (command); - foreach (string alias in command.Aliases) + foreach (var alias in command.Aliases) { _commandsByAlias.Add (alias, command); } diff --git a/src/Terminal.Gui.Cli/EmbeddedMarkdownHelpProvider.cs b/src/Terminal.Gui.Cli/EmbeddedMarkdownHelpProvider.cs index 79242d4..f2b5904 100644 --- a/src/Terminal.Gui.Cli/EmbeddedMarkdownHelpProvider.cs +++ b/src/Terminal.Gui.Cli/EmbeddedMarkdownHelpProvider.cs @@ -39,7 +39,7 @@ public EmbeddedMarkdownHelpProvider (Assembly resourceAssembly) return null; } - using var reader = new StreamReader (stream, Encoding.UTF8); + using StreamReader reader = new (stream, Encoding.UTF8); return reader.ReadToEnd (); } } diff --git a/src/Terminal.Gui.Cli/HelpCommand.cs b/src/Terminal.Gui.Cli/HelpCommand.cs index 0e3fac7..f5a5fba 100644 --- a/src/Terminal.Gui.Cli/HelpCommand.cs +++ b/src/Terminal.Gui.Cli/HelpCommand.cs @@ -5,8 +5,8 @@ namespace Terminal.Gui.Cli; /// Interactive TUI markdown help viewer, with --cat support for ANSI stdout. public sealed class HelpCommand : IViewerCommand { - private readonly ICommandRegistry _registry; private readonly IHelpProvider _helpProvider; + private readonly ICommandRegistry _registry; /// Creates a help command that lazily reads command metadata from . public HelpCommand (ICommandRegistry registry, IHelpProvider helpProvider) @@ -37,14 +37,16 @@ public HelpCommand (ICommandRegistry registry, IHelpProvider helpProvider) public bool AcceptsPositionalArgs => true; /// - public Task RunAsync (IApplication app, string? initial, CommandRunOptions options, CancellationToken cancellationToken) + public Task RunAsync (IApplication app, string? initial, CommandRunOptions options, + CancellationToken cancellationToken) { - string markdown = ResolveHelp (options); + var markdown = ResolveHelp (options); return Task.FromResult (new CommandResult (CommandStatus.Ok, markdown, null, null)); } /// - public Task RenderCatAsync (CommandRunOptions options, TextWriter stdout, CancellationToken cancellationToken) + public Task RenderCatAsync (CommandRunOptions options, TextWriter stdout, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull (stdout); MarkdownRenderer.RenderToAnsi (ResolveHelp (options), stdout); @@ -53,11 +55,14 @@ public Task RunAsync (IApplication app, string? initial, CommandR private string ResolveHelp (CommandRunOptions options) { - if (options.Arguments.Count > 0 && _registry.TryResolve (options.Arguments[0], out ICliCommand? command) && command is not null) + if (options.Arguments.Count > 0 && _registry.TryResolve (options.Arguments[0], out ICliCommand? command) && + command is not null) { - return _helpProvider.GetCommandHelp (command) ?? new MetadataHelpProvider ().GetCommandHelp (command) ?? string.Empty; + return _helpProvider.GetCommandHelp (command) ?? + new MetadataHelpProvider ().GetCommandHelp (command) ?? string.Empty; } - return _helpProvider.GetRootHelp (_registry) ?? new MetadataHelpProvider ().GetRootHelp (_registry) ?? string.Empty; + return _helpProvider.GetRootHelp (_registry) ?? + new MetadataHelpProvider ().GetRootHelp (_registry) ?? string.Empty; } } diff --git a/src/Terminal.Gui.Cli/ICliCommand.cs b/src/Terminal.Gui.Cli/ICliCommand.cs index 6105eb2..349108f 100644 --- a/src/Terminal.Gui.Cli/ICliCommand.cs +++ b/src/Terminal.Gui.Cli/ICliCommand.cs @@ -27,10 +27,13 @@ public interface ICliCommand bool AcceptsPositionalArgs => false; /// - /// Validates the --initial value before Terminal.Gui starts. The default permits any value; - /// commands override this method when they need command-specific validation. + /// Validates the --initial value before Terminal.Gui starts. The default permits any value; + /// commands override this method when they need command-specific validation. /// - bool TryValidateInitial (string initial, CommandRunOptions options) => true; + bool TryValidateInitial (string initial, CommandRunOptions options) + { + return true; + } /// Runs the command after the host has initialized Terminal.Gui. Task RunAsync ( diff --git a/src/Terminal.Gui.Cli/ICliCommandGeneric.cs b/src/Terminal.Gui.Cli/ICliCommandGeneric.cs index ebfa88c..f0fe8cf 100644 --- a/src/Terminal.Gui.Cli/ICliCommandGeneric.cs +++ b/src/Terminal.Gui.Cli/ICliCommandGeneric.cs @@ -5,13 +5,6 @@ namespace Terminal.Gui.Cli; /// Typed command that returns a value. public interface ICliCommand : ICliCommand { - /// Runs the command and returns a typed result. - new Task> RunAsync ( - IApplication app, - string? initial, - CommandRunOptions options, - CancellationToken cancellationToken); - async Task ICliCommand.RunAsync ( IApplication app, string? initial, @@ -19,6 +12,13 @@ async Task ICliCommand.RunAsync ( CancellationToken cancellationToken) { CommandResult result = await RunAsync (app, initial, options, cancellationToken); - return new (result.Status, result.Value, result.ErrorCode, result.ErrorMessage); + return new CommandResult (result.Status, result.Value, result.ErrorCode, result.ErrorMessage); } + + /// Runs the command and returns a typed result. + new Task> RunAsync ( + IApplication app, + string? initial, + CommandRunOptions options, + CancellationToken cancellationToken); } diff --git a/src/Terminal.Gui.Cli/ICommandRegistry.cs b/src/Terminal.Gui.Cli/ICommandRegistry.cs index 936c398..1c52055 100644 --- a/src/Terminal.Gui.Cli/ICommandRegistry.cs +++ b/src/Terminal.Gui.Cli/ICommandRegistry.cs @@ -3,15 +3,15 @@ namespace Terminal.Gui.Cli; /// Manages alias-to-command lookup. public interface ICommandRegistry { + /// All registered commands in registration order. + IReadOnlyCollection All { get; } + /// Registers a command instance. /// - /// Thrown when PrimaryAlias is not present in Aliases, or any alias is already registered. + /// Thrown when PrimaryAlias is not present in Aliases, or any alias is already registered. /// void Register (ICliCommand command); /// Resolves an alias case-insensitively. bool TryResolve (string alias, out ICliCommand? command); - - /// All registered commands in registration order. - IReadOnlyCollection All { get; } } diff --git a/src/Terminal.Gui.Cli/IViewerCommand.cs b/src/Terminal.Gui.Cli/IViewerCommand.cs index 6e88196..2cc7d73 100644 --- a/src/Terminal.Gui.Cli/IViewerCommand.cs +++ b/src/Terminal.Gui.Cli/IViewerCommand.cs @@ -1,17 +1,20 @@ namespace Terminal.Gui.Cli; /// -/// Viewer command. Viewers can be interactive TUI commands or headless content commands, -/// but they are invoked through the viewer path and default to fullscreen when a TUI is used. +/// Viewer command. Viewers can be interactive TUI commands or headless content commands, +/// but they are invoked through the viewer path and default to fullscreen when a TUI is used. /// public interface IViewerCommand : ICliCommand { /// - /// Renders content to stdout without launching the TUI. Called when --cat is set. - /// Return null to indicate --cat is not supported and normal TUI dispatch should continue. + /// Renders content to stdout without launching the TUI. Called when --cat is set. + /// Return null to indicate --cat is not supported and normal TUI dispatch should continue. /// Task RenderCatAsync ( CommandRunOptions options, TextWriter stdout, - CancellationToken cancellationToken) => Task.FromResult (null); + CancellationToken cancellationToken) + { + return Task.FromResult (null); + } } diff --git a/src/Terminal.Gui.Cli/MarkdownRenderer.cs b/src/Terminal.Gui.Cli/MarkdownRenderer.cs index cb6b612..29ab182 100644 --- a/src/Terminal.Gui.Cli/MarkdownRenderer.cs +++ b/src/Terminal.Gui.Cli/MarkdownRenderer.cs @@ -11,7 +11,8 @@ public static void RenderToAnsi (string markdown, TextWriter output) ArgumentNullException.ThrowIfNull (markdown); ArgumentNullException.ThrowIfNull (output); - string rendered = new Markdown ().RenderToAnsi (markdown, Math.Max (1, Console.IsOutputRedirected ? 80 : Console.WindowWidth)); + var rendered = new Markdown ().RenderToAnsi (markdown, + Math.Max (1, Console.IsOutputRedirected ? 80 : Console.WindowWidth)); output.Write (TerminalEscapeSanitizer.SanitizeRenderedOutput (rendered)); } } diff --git a/src/Terminal.Gui.Cli/MetadataHelpProvider.cs b/src/Terminal.Gui.Cli/MetadataHelpProvider.cs index 045286a..a546c04 100644 --- a/src/Terminal.Gui.Cli/MetadataHelpProvider.cs +++ b/src/Terminal.Gui.Cli/MetadataHelpProvider.cs @@ -10,7 +10,7 @@ public sealed class MetadataHelpProvider : IHelpProvider { ArgumentNullException.ThrowIfNull (registry); - var builder = new StringBuilder (); + StringBuilder builder = new (); builder.AppendLine ("Commands:"); foreach (ICliCommand command in registry.All) @@ -37,7 +37,7 @@ public sealed class MetadataHelpProvider : IHelpProvider { ArgumentNullException.ThrowIfNull (command); - var builder = new StringBuilder (); + StringBuilder builder = new (); builder.AppendLine ($"# {command.PrimaryAlias}"); builder.AppendLine (command.Description); @@ -48,7 +48,7 @@ public sealed class MetadataHelpProvider : IHelpProvider foreach (CommandOptionDescriptor option in command.Options) { - string shortName = option.ShortName is null ? string.Empty : $" -{option.ShortName},"; + var shortName = option.ShortName is null ? string.Empty : $" -{option.ShortName},"; builder.AppendLine ($" {shortName} --{option.Name}\t{option.Description}"); } } diff --git a/src/Terminal.Gui.Cli/OpenCliWriter.cs b/src/Terminal.Gui.Cli/OpenCliWriter.cs index e9d754d..86fa89b 100644 --- a/src/Terminal.Gui.Cli/OpenCliWriter.cs +++ b/src/Terminal.Gui.Cli/OpenCliWriter.cs @@ -11,14 +11,14 @@ public static string Generate (ICommandRegistry registry, CliHostOptions options ArgumentNullException.ThrowIfNull (registry); ArgumentNullException.ThrowIfNull (options); - var builder = new StringBuilder (); + StringBuilder builder = new (); builder.Append ('{'); AppendProperty (builder, "name", options.ApplicationName); builder.Append (','); AppendProperty (builder, "version", options.Version ?? "0.0.0"); builder.Append (",\"commands\":["); - bool firstCommand = true; + var firstCommand = true; foreach (ICliCommand command in registry.All) { @@ -32,9 +32,12 @@ public static string Generate (ICommandRegistry registry, CliHostOptions options } builder.Append ("],\"frameworkOptions\":["); - string[] frameworkOptions = ["help", "version", "opencli", "initial", "title", "json", "timeout", "fullscreen", "cat", "output", "rows"]; + string[] frameworkOptions = + [ + "help", "version", "opencli", "initial", "title", "json", "timeout", "fullscreen", "cat", "output", "rows" + ]; - for (int i = 0; i < frameworkOptions.Length; i++) + for (var i = 0; i < frameworkOptions.Length; i++) { if (i > 0) { @@ -62,7 +65,7 @@ private static void AppendCommand (StringBuilder builder, ICliCommand command) AppendProperty (builder, "resultType", TypeNames.WireName (command.ResultType)); builder.Append (",\"aliases\":["); - for (int i = 0; i < command.Aliases.Count; i++) + for (var i = 0; i < command.Aliases.Count; i++) { if (i > 0) { @@ -76,7 +79,7 @@ private static void AppendCommand (StringBuilder builder, ICliCommand command) builder.Append ("],\"options\":["); - for (int i = 0; i < command.Options.Count; i++) + for (var i = 0; i < command.Options.Count; i++) { if (i > 0) { @@ -126,9 +129,9 @@ private static void AppendProperty (StringBuilder builder, string name, string v private static string Escape (string value) { - var builder = new StringBuilder (value.Length); + StringBuilder builder = new (value.Length); - foreach (char c in value) + foreach (var c in value) { switch (c) { diff --git a/src/Terminal.Gui.Cli/ResultWriter.cs b/src/Terminal.Gui.Cli/ResultWriter.cs index b4610a5..1c73fd1 100644 --- a/src/Terminal.Gui.Cli/ResultWriter.cs +++ b/src/Terminal.Gui.Cli/ResultWriter.cs @@ -4,13 +4,14 @@ namespace Terminal.Gui.Cli; public static class ResultWriter { /// Writes and returns false when output file creation fails. - public static bool Write (CommandResult result, bool jsonOutput, TextWriter stdout, TextWriter stderr, string? outputPath = null) + public static bool Write (CommandResult result, bool jsonOutput, TextWriter stdout, TextWriter stderr, + string? outputPath = null) { ArgumentNullException.ThrowIfNull (stdout); ArgumentNullException.ThrowIfNull (stderr); - string text = jsonOutput ? ToEnvelope (result).ToJson () : ToPlainText (result); - bool writeToOutput = result.Status is CommandStatus.Ok or CommandStatus.NoResult; + var text = jsonOutput ? ToEnvelope (result).ToJson () : ToPlainText (result); + var writeToOutput = result.Status is CommandStatus.Ok or CommandStatus.NoResult; TextWriter writer = result.Status == CommandStatus.Error && !jsonOutput ? stderr : stdout; if (writeToOutput && outputPath is not null) @@ -48,7 +49,8 @@ private static JsonEnvelope ToEnvelope (CommandResult result) CommandStatus.Ok => JsonEnvelope.Ok (result.Value), CommandStatus.Cancelled => JsonEnvelope.Cancelled (), CommandStatus.NoResult => JsonEnvelope.NoResult (), - CommandStatus.Error => JsonEnvelope.Error (result.ErrorCode ?? "error", result.ErrorMessage ?? "Command failed."), + CommandStatus.Error => JsonEnvelope.Error (result.ErrorCode ?? "error", + result.ErrorMessage ?? "Command failed."), _ => JsonEnvelope.Error ("error", "Command failed.") }; } diff --git a/src/Terminal.Gui.Cli/TerminalEscapeSanitizer.cs b/src/Terminal.Gui.Cli/TerminalEscapeSanitizer.cs index e7738a2..550502d 100644 --- a/src/Terminal.Gui.Cli/TerminalEscapeSanitizer.cs +++ b/src/Terminal.Gui.Cli/TerminalEscapeSanitizer.cs @@ -8,23 +8,23 @@ public static class TerminalEscapeSanitizer /// Sanitizes user-supplied content before it reaches a terminal driver. public static string? Sanitize (string? input) { - return input is null ? null : StripEscapes (input, preserveSgr: false); + return input is null ? null : StripEscapes (input, false); } /// Sanitizes rendered ANSI, preserving only SGR CSI sequences generated by trusted renderers. public static string SanitizeRenderedOutput (string renderedAnsi) { ArgumentNullException.ThrowIfNull (renderedAnsi); - return StripEscapes (renderedAnsi, preserveSgr: true); + return StripEscapes (renderedAnsi, true); } private static string StripEscapes (string input, bool preserveSgr) { - var output = new StringBuilder (input.Length); + StringBuilder output = new (input.Length); - for (int i = 0; i < input.Length; i++) + for (var i = 0; i < input.Length; i++) { - char c = input[i]; + var c = input[i]; if (c != '\u001b') { @@ -32,7 +32,7 @@ private static string StripEscapes (string input, bool preserveSgr) continue; } - if (preserveSgr && TryReadSgr (input, i, out int endIndex)) + if (preserveSgr && TryReadSgr (input, i, out var endIndex)) { output.Append (input, i, endIndex - i + 1); i = endIndex; @@ -54,7 +54,7 @@ private static bool TryReadSgr (string input, int start, out int endIndex) return false; } - int i = start + 2; + var i = start + 2; while (i < input.Length && (char.IsDigit (input[i]) || input[i] == ';')) { @@ -77,11 +77,11 @@ private static int SkipEscape (string input, int start) return start; } - char introducer = input[start + 1]; + var introducer = input[start + 1]; if (introducer == '[') { - for (int i = start + 2; i < input.Length; i++) + for (var i = start + 2; i < input.Length; i++) { if (input[i] is >= '@' and <= '~') { @@ -94,7 +94,7 @@ private static int SkipEscape (string input, int start) if (introducer == ']') { - for (int i = start + 2; i < input.Length; i++) + for (var i = start + 2; i < input.Length; i++) { if (input[i] == '\u0007') { diff --git a/src/Terminal.Gui.Cli/TypeNames.cs b/src/Terminal.Gui.Cli/TypeNames.cs index f2554cf..63d3e18 100644 --- a/src/Terminal.Gui.Cli/TypeNames.cs +++ b/src/Terminal.Gui.Cli/TypeNames.cs @@ -25,7 +25,8 @@ public static string WireName (Type type) return "boolean"; } - if (nullableType == typeof (int) || nullableType == typeof (long) || nullableType == typeof (short) || nullableType == typeof (byte)) + if (nullableType == typeof (int) || nullableType == typeof (long) || nullableType == typeof (short) || + nullableType == typeof (byte)) { return "integer"; } diff --git a/tests/Terminal.Gui.Cli.Tests/ArgParserTests.cs b/tests/Terminal.Gui.Cli.Tests/ArgParserTests.cs index 36e53db..05a8750 100644 --- a/tests/Terminal.Gui.Cli.Tests/ArgParserTests.cs +++ b/tests/Terminal.Gui.Cli.Tests/ArgParserTests.cs @@ -8,10 +8,11 @@ public sealed class ArgParserTests [Fact] public void Parse_SeparatesFrameworkGlobalsCommandOptionsAndPositionals () { - var parser = new ArgParser ([new GlobalOptionDescriptor ("profile", "P", "Profile", false, true)]); - var command = new TestCommand (acceptsPositionalArgs: true); + ArgParser parser = new ([new GlobalOptionDescriptor ("profile", "P", "Profile", false, true)]); + TestCommand command = new (true); - ArgParser.ParseResult result = parser.Parse (["--profile", "dev", "pick", "--json", "--name", "value", "arg"], command); + ArgParser.ParseResult result = + parser.Parse (["--profile", "dev", "pick", "--json", "--name", "value", "arg"], command); Assert.True (result.Success, result.Error); Assert.Equal ("pick", result.Alias); @@ -40,9 +41,9 @@ public void TryParseTimeout_WithOverflowValue_ReturnsFalse () [Fact] public void Parse_RejectsMissingRequiredCommandOption () { - var parser = new ArgParser ([]); + ArgParser parser = new ([]); - ArgParser.ParseResult result = parser.Parse (["pick"], new TestCommand (acceptsPositionalArgs: false)); + ArgParser.ParseResult result = parser.Parse (["pick"], new TestCommand (false)); Assert.False (result.Success); Assert.Contains ("--name", result.Error); @@ -60,11 +61,13 @@ private sealed class TestCommand (bool acceptsPositionalArgs) : ICliCommand public Type ResultType => typeof (string); - public IReadOnlyList Options { get; } = [new ("name", "n", typeof (string), "Name", true, null)]; + public IReadOnlyList Options { get; } = + [new ("name", "n", typeof (string), "Name", true, null)]; public bool AcceptsPositionalArgs { get; } = acceptsPositionalArgs; - public Task RunAsync (IApplication app, string? initial, CommandRunOptions options, CancellationToken cancellationToken) + public Task RunAsync (IApplication app, string? initial, CommandRunOptions options, + CancellationToken cancellationToken) { return Task.FromResult (new CommandResult (CommandStatus.Ok, "ok", null, null)); } diff --git a/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs b/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs index 4d31eed..ae35150 100644 --- a/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs +++ b/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs @@ -1,5 +1,5 @@ -using Xunit; using Terminal.Gui.App; +using Xunit; namespace Terminal.Gui.Cli.Tests; @@ -8,15 +8,15 @@ public sealed class CliHostTests [Fact] public async Task RunAsync_OpenCli_WritesRegisteredBuiltIns () { - var host = new CliHost (options => + CliHost host = new (options => { options.ApplicationName = "sample"; options.Version = "1.2.3"; }); - using var stdout = new StringWriter (); - using var stderr = new StringWriter (); + using StringWriter stdout = new (); + using StringWriter stderr = new (); - int exitCode = await host.RunAsync (["--opencli"], TestContext.Current.CancellationToken, stdout, stderr); + var exitCode = await host.RunAsync (["--opencli"], TestContext.Current.CancellationToken, stdout, stderr); Assert.Equal (ExitCodes.Ok, exitCode); Assert.Contains ("\"name\":\"sample\"", stdout.ToString ()); @@ -27,15 +27,16 @@ public async Task RunAsync_OpenCli_WritesRegisteredBuiltIns () [Fact] public async Task RunAsync_AgentGuideCat_WritesLiteralWithoutStartingTui () { - var host = new CliHost (options => + CliHost host = new (options => { options.AgentGuide = "# Guide"; options.AgentGuideIsResource = false; }); - using var stdout = new StringWriter (); - using var stderr = new StringWriter (); + using StringWriter stdout = new (); + using StringWriter stderr = new (); - int exitCode = await host.RunAsync (["agent-guide", "--cat"], TestContext.Current.CancellationToken, stdout, stderr); + var exitCode = await host.RunAsync (["agent-guide", "--cat"], TestContext.Current.CancellationToken, stdout, + stderr); Assert.Equal (ExitCodes.Ok, exitCode); Assert.Equal ("# Guide", stdout.ToString ()); @@ -45,14 +46,14 @@ public async Task RunAsync_AgentGuideCat_WritesLiteralWithoutStartingTui () [Fact] public async Task RunAsync_CommandCancellation_ReturnsCancelledExitCode () { - var host = new CliHost (); + CliHost host = new (); host.Registry.Register (new CancellingCatCommand ()); - using var stdout = new StringWriter (); - using var stderr = new StringWriter (); - using var cancellation = new CancellationTokenSource (); + using StringWriter stdout = new (); + using StringWriter stderr = new (); + using CancellationTokenSource cancellation = new (); cancellation.Cancel (); - int exitCode = await host.RunAsync (["cancel", "--cat"], cancellation.Token, stdout, stderr); + var exitCode = await host.RunAsync (["cancel", "--cat"], cancellation.Token, stdout, stderr); Assert.Equal (ExitCodes.Cancelled, exitCode); Assert.Equal (string.Empty, stdout.ToString ()); @@ -73,12 +74,14 @@ private sealed class CancellingCatCommand : IViewerCommand public IReadOnlyList Options { get; } = []; - public Task RunAsync (IApplication app, string? initial, CommandRunOptions options, CancellationToken cancellationToken) + public Task RunAsync (IApplication app, string? initial, CommandRunOptions options, + CancellationToken cancellationToken) { throw new OperationCanceledException (cancellationToken); } - public Task RenderCatAsync (CommandRunOptions options, TextWriter stdout, CancellationToken cancellationToken) + public Task RenderCatAsync (CommandRunOptions options, TextWriter stdout, + CancellationToken cancellationToken) { throw new OperationCanceledException (cancellationToken); } diff --git a/tests/Terminal.Gui.Cli.Tests/CommandRegistryTests.cs b/tests/Terminal.Gui.Cli.Tests/CommandRegistryTests.cs index be19c4f..f32f1d7 100644 --- a/tests/Terminal.Gui.Cli.Tests/CommandRegistryTests.cs +++ b/tests/Terminal.Gui.Cli.Tests/CommandRegistryTests.cs @@ -8,8 +8,8 @@ public sealed class CommandRegistryTests [Fact] public void Register_ResolvesAliasesCaseInsensitively () { - var registry = new CommandRegistry (); - var command = new TestCommand ("pick", ["pick", "select"]); + CommandRegistry registry = new (); + TestCommand command = new ("pick", ["pick", "select"]); registry.Register (command); @@ -20,10 +20,11 @@ public void Register_ResolvesAliasesCaseInsensitively () [Fact] public void Register_RejectsDuplicateAliasesCaseInsensitively () { - var registry = new CommandRegistry (); + CommandRegistry registry = new (); registry.Register (new TestCommand ("pick", ["pick"])); - Assert.Throws (() => registry.Register (new TestCommand ("other", ["PICK", "other"]))); + Assert.Throws (() => + registry.Register (new TestCommand ("other", ["PICK", "other"]))); } private sealed class TestCommand (string primaryAlias, IReadOnlyList aliases) : ICliCommand @@ -40,7 +41,8 @@ private sealed class TestCommand (string primaryAlias, IReadOnlyList ali public IReadOnlyList Options { get; } = []; - public Task RunAsync (IApplication app, string? initial, CommandRunOptions options, CancellationToken cancellationToken) + public Task RunAsync (IApplication app, string? initial, CommandRunOptions options, + CancellationToken cancellationToken) { return Task.FromResult (new CommandResult (CommandStatus.Ok, "ok", null, null)); } diff --git a/tests/Terminal.Gui.Cli.Tests/OutputTests.cs b/tests/Terminal.Gui.Cli.Tests/OutputTests.cs index be629ea..d4a88d0 100644 --- a/tests/Terminal.Gui.Cli.Tests/OutputTests.cs +++ b/tests/Terminal.Gui.Cli.Tests/OutputTests.cs @@ -8,7 +8,7 @@ public sealed class OutputTests [Fact] public void JsonEnvelope_ToJson_UsesCamelCaseAndOmitsNulls () { - string json = JsonEnvelope.Ok ("value").ToJson (); + var json = JsonEnvelope.Ok ("value").ToJson (); using JsonDocument document = JsonDocument.Parse (json); Assert.Equal (1, document.RootElement.GetProperty ("schemaVersion").GetInt32 ()); @@ -20,10 +20,11 @@ public void JsonEnvelope_ToJson_UsesCamelCaseAndOmitsNulls () [Fact] public void ResultWriter_WritesErrorsToStderrInPlainText () { - using var stdout = new StringWriter (); - using var stderr = new StringWriter (); + using StringWriter stdout = new (); + using StringWriter stderr = new (); - bool success = ResultWriter.Write (new CommandResult (CommandStatus.Error, null, "validation", "bad"), false, stdout, stderr); + var success = ResultWriter.Write (new CommandResult (CommandStatus.Error, null, "validation", "bad"), false, + stdout, stderr); Assert.True (success); Assert.Equal (string.Empty, stdout.ToString ()); @@ -34,6 +35,7 @@ public void ResultWriter_WritesErrorsToStderrInPlainText () public void TerminalEscapeSanitizer_RemovesOscAndPreservesRenderedSgr () { Assert.Equal ("title", TerminalEscapeSanitizer.Sanitize ("\u001b]0;bad\u0007title")); - Assert.Equal ("\u001b[1mstrong\u001b[0m", TerminalEscapeSanitizer.SanitizeRenderedOutput ("\u001b[1mstrong\u001b[0m")); + Assert.Equal ("\u001b[1mstrong\u001b[0m", + TerminalEscapeSanitizer.SanitizeRenderedOutput ("\u001b[1mstrong\u001b[0m")); } } From f19c9a67d5339aa0a50635544c708916f577b41b Mon Sep 17 00:00:00 2001 From: Tig Date: Sun, 24 May 2026 07:43:52 -0600 Subject: [PATCH 09/11] Add working example app demonstrating Terminal.Gui.Cli library - GreetCommand: input command with --formal per-command option - InfoCommand: viewer command with --cat support - Embedded agent-guide.md resource wired via CliHostOptions - Program.cs wires CliHost with commands and agent-guide - Fix ArgParser two-pass bug: first parse (no command) now skips unknown options after alias so the second pass validates them Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../GreetCommand.cs | 47 ++++++++++++++ .../InfoCommand.cs | 58 +++++++++++++++++ .../Terminal.Gui.Cli.ExampleApp/Program.cs | 18 ++++- .../Resources/agent-guide.md | 65 +++++++++++++++++++ .../Terminal.Gui.Cli.ExampleApp.csproj | 4 ++ src/Terminal.Gui.Cli/ArgParser.cs | 13 ++++ 6 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 examples/Terminal.Gui.Cli.ExampleApp/GreetCommand.cs create mode 100644 examples/Terminal.Gui.Cli.ExampleApp/InfoCommand.cs create mode 100644 examples/Terminal.Gui.Cli.ExampleApp/Resources/agent-guide.md diff --git a/examples/Terminal.Gui.Cli.ExampleApp/GreetCommand.cs b/examples/Terminal.Gui.Cli.ExampleApp/GreetCommand.cs new file mode 100644 index 0000000..87e7f90 --- /dev/null +++ b/examples/Terminal.Gui.Cli.ExampleApp/GreetCommand.cs @@ -0,0 +1,47 @@ +using Terminal.Gui.App; +using Terminal.Gui.Cli; + +namespace Terminal.Gui.Cli.ExampleApp; + +/// An input command that prompts for a name and returns a greeting. +public sealed class GreetCommand : ICliCommand +{ + /// + public string PrimaryAlias => "greet"; + + /// + public IReadOnlyList Aliases { get; } = ["greet"]; + + /// + public string Description => "Prompt for a name and return a greeting."; + + /// + public CommandKind Kind => CommandKind.Input; + + /// + public Type ResultType => typeof (string); + + /// + public IReadOnlyList Options { get; } = + [ + new CommandOptionDescriptor ("formal", "f", typeof (bool), "Use a formal greeting style.", false, null) + ]; + + /// + public Task> RunAsync ( + IApplication app, + string? initial, + CommandRunOptions options, + CancellationToken cancellationToken) + { + var name = initial ?? "World"; + var formal = options.CommandOptions.TryGetValue ("formal", out var formalValue) + && formalValue.Equals ("true", StringComparison.OrdinalIgnoreCase); + + var greeting = formal + ? $"Good day, {name}. It is a pleasure to meet you." + : $"Hello, {name}!"; + + return Task.FromResult (new CommandResult (CommandStatus.Ok, greeting, null, null)); + } +} diff --git a/examples/Terminal.Gui.Cli.ExampleApp/InfoCommand.cs b/examples/Terminal.Gui.Cli.ExampleApp/InfoCommand.cs new file mode 100644 index 0000000..4484020 --- /dev/null +++ b/examples/Terminal.Gui.Cli.ExampleApp/InfoCommand.cs @@ -0,0 +1,58 @@ +using Terminal.Gui.App; +using Terminal.Gui.Cli; + +namespace Terminal.Gui.Cli.ExampleApp; + +/// A viewer command that displays application information. +public sealed class InfoCommand : IViewerCommand +{ + private const string InfoText = """ + Example App v1.0.0 + A demonstration of the Terminal.Gui.Cli library. + + This app shows how to: + - Register input and viewer commands + - Use --help, --json, --opencli, and agent-guide + - Embed an agent-guide.md resource + - Support --cat for headless content rendering + """; + + /// + public string PrimaryAlias => "info"; + + /// + public IReadOnlyList Aliases { get; } = ["info"]; + + /// + public string Description => "Display application information."; + + /// + public CommandKind Kind => CommandKind.Viewer; + + /// + public Type ResultType => typeof (string); + + /// + public IReadOnlyList Options { get; } = []; + + /// + public Task RunAsync ( + IApplication app, + string? initial, + CommandRunOptions options, + CancellationToken cancellationToken) + { + return Task.FromResult (new CommandResult (CommandStatus.Ok, InfoText, null, null)); + } + + /// + public Task RenderCatAsync ( + CommandRunOptions options, + TextWriter stdout, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull (stdout); + stdout.Write (InfoText); + return Task.FromResult (new CommandResult (CommandStatus.Ok, null, null, null)); + } +} diff --git a/examples/Terminal.Gui.Cli.ExampleApp/Program.cs b/examples/Terminal.Gui.Cli.ExampleApp/Program.cs index 1c99635..e241dff 100644 --- a/examples/Terminal.Gui.Cli.ExampleApp/Program.cs +++ b/examples/Terminal.Gui.Cli.ExampleApp/Program.cs @@ -1 +1,17 @@ -Console.WriteLine ("Terminal.Gui.Cli scaffold example app."); +using System.Reflection; +using Terminal.Gui.Cli; +using Terminal.Gui.Cli.ExampleApp; + +CliHost host = new (options => +{ + options.ApplicationName = "example-app"; + options.Version = "1.0.0"; + options.AgentGuide = "Terminal.Gui.Cli.ExampleApp.agent-guide.md"; + options.AgentGuideIsResource = true; + options.ResourceAssembly = Assembly.GetExecutingAssembly (); +}); + +host.Registry.Register (new GreetCommand ()); +host.Registry.Register (new InfoCommand ()); + +return await host.RunAsync (args); diff --git a/examples/Terminal.Gui.Cli.ExampleApp/Resources/agent-guide.md b/examples/Terminal.Gui.Cli.ExampleApp/Resources/agent-guide.md new file mode 100644 index 0000000..4f324a3 --- /dev/null +++ b/examples/Terminal.Gui.Cli.ExampleApp/Resources/agent-guide.md @@ -0,0 +1,65 @@ +# Example App Agent Guide + +This document describes how AI agents should interact with `example-app`. + +## Available Commands + +### greet + +An input command that prompts the user for their name and returns a greeting. + +- **Alias:** `greet` +- **Kind:** Input +- **Result type:** `string` +- **Options:** + - `--formal` / `-f`: Use a formal greeting style (flag). + +**Usage:** + +```bash +example-app greet --initial "World" +example-app greet --initial "World" --json +example-app greet --initial "World" --formal +``` + +### info + +A viewer command that displays application information. + +- **Alias:** `info` +- **Kind:** Viewer +- **Result type:** `string` +- **Supports `--cat`:** Yes + +**Usage:** + +```bash +example-app info --cat +example-app info --cat --json +``` + +## Framework Options + +All commands support these framework options: + +| Option | Description | +|--------|-------------| +| `--help` / `-h` | Show help | +| `--version` | Show version | +| `--opencli` | Emit OpenCLI metadata JSON | +| `--json` | Emit JSON envelope output | +| `--initial ` | Pre-fill input value | +| `--timeout ` | Cancel after duration (e.g., `30s`, `5m`) | +| `--output ` / `-o` | Write output to file | + +## JSON Envelope + +All commands support `--json` to emit structured output: + +```json +{ + "schemaVersion": 1, + "status": "ok", + "value": "Hello, World!" +} +``` diff --git a/examples/Terminal.Gui.Cli.ExampleApp/Terminal.Gui.Cli.ExampleApp.csproj b/examples/Terminal.Gui.Cli.ExampleApp/Terminal.Gui.Cli.ExampleApp.csproj index ba00716..3737551 100644 --- a/examples/Terminal.Gui.Cli.ExampleApp/Terminal.Gui.Cli.ExampleApp.csproj +++ b/examples/Terminal.Gui.Cli.ExampleApp/Terminal.Gui.Cli.ExampleApp.csproj @@ -11,4 +11,8 @@ + + + + diff --git a/src/Terminal.Gui.Cli/ArgParser.cs b/src/Terminal.Gui.Cli/ArgParser.cs index f4539d4..da84d7e 100644 --- a/src/Terminal.Gui.Cli/ArgParser.cs +++ b/src/Terminal.Gui.Cli/ArgParser.cs @@ -117,6 +117,19 @@ public ParseResult Parse (string[] args, ICliCommand? command = null) continue; } + if (command is null && alias is not null) + { + // First pass (no command): skip unknown options after alias; they'll be validated in second pass. + index++; + + if (index < args.Length && !args[index].StartsWith ('-')) + { + index++; + } + + continue; + } + return ParseResult.Fail ($"Unknown option '{token}'."); } From 5763cced6b8080e433174a6716fe17ff8b970542 Mon Sep 17 00:00:00 2001 From: Tig Date: Sun, 24 May 2026 07:57:24 -0600 Subject: [PATCH 10/11] Fix ReSharper cleanup violations in example app - Remove redundant 'using Terminal.Gui.Cli' (implicit via namespace) - Use target-typed new for CommandOptionDescriptor Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- examples/Terminal.Gui.Cli.ExampleApp/GreetCommand.cs | 3 +-- examples/Terminal.Gui.Cli.ExampleApp/InfoCommand.cs | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/examples/Terminal.Gui.Cli.ExampleApp/GreetCommand.cs b/examples/Terminal.Gui.Cli.ExampleApp/GreetCommand.cs index 87e7f90..f0308ca 100644 --- a/examples/Terminal.Gui.Cli.ExampleApp/GreetCommand.cs +++ b/examples/Terminal.Gui.Cli.ExampleApp/GreetCommand.cs @@ -1,5 +1,4 @@ using Terminal.Gui.App; -using Terminal.Gui.Cli; namespace Terminal.Gui.Cli.ExampleApp; @@ -24,7 +23,7 @@ public sealed class GreetCommand : ICliCommand /// public IReadOnlyList Options { get; } = [ - new CommandOptionDescriptor ("formal", "f", typeof (bool), "Use a formal greeting style.", false, null) + new ("formal", "f", typeof (bool), "Use a formal greeting style.", false, null) ]; /// diff --git a/examples/Terminal.Gui.Cli.ExampleApp/InfoCommand.cs b/examples/Terminal.Gui.Cli.ExampleApp/InfoCommand.cs index 4484020..b9331d2 100644 --- a/examples/Terminal.Gui.Cli.ExampleApp/InfoCommand.cs +++ b/examples/Terminal.Gui.Cli.ExampleApp/InfoCommand.cs @@ -1,5 +1,4 @@ using Terminal.Gui.App; -using Terminal.Gui.Cli; namespace Terminal.Gui.Cli.ExampleApp; From ef82d5fe1b2a157fafef46cd865b0f39dacf01d9 Mon Sep 17 00:00:00 2001 From: Tig Date: Sun, 24 May 2026 07:57:56 -0600 Subject: [PATCH 11/11] Update CLAUDE.md with ReSharper cleanup instructions - Document the CI style-check pipeline (jb cleanupcode + dotnet format) - Add pre-commit verification steps agents must run - Add coding standards for target-typed new() and redundant usings - Update project status from 'scaffolding' to library description Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CLAUDE.md | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5126311..63c2c87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,8 +4,9 @@ This file provides guidance to AI coding agents working in this repository. ## Project status -This repository currently contains scaffolding only for `Terminal.Gui.Cli`. -No library implementation is present yet. +This repository implements the `Terminal.Gui.Cli` library — a hosting layer +for Terminal.Gui apps that provides CLI parsing, dispatch, JSON output, and +AI-agent discoverability. ## Source of truth @@ -24,12 +25,27 @@ If guidance conflicts, follow `specs/constitution.md`. ## Build and test -- `dotnet restore Terminal.Gui.Cli.slnx` -- `dotnet build Terminal.Gui.Cli.slnx --no-restore -c Debug` -- `dotnet format Terminal.Gui.Cli.slnx --no-restore --verify-no-changes` -- `dotnet run --project tests/Terminal.Gui.Cli.Tests --no-build -c Debug` -- `dotnet run --project tests/Terminal.Gui.Cli.IntegrationTests --no-build -c Debug` -- `dotnet run --project tests/Terminal.Gui.Cli.SmokeTests --no-build -c Debug` +```bash +dotnet restore Terminal.Gui.Cli.slnx +dotnet build Terminal.Gui.Cli.slnx --no-restore -c Debug +dotnet run --project tests/Terminal.Gui.Cli.Tests --no-build -c Debug +dotnet run --project tests/Terminal.Gui.Cli.IntegrationTests --no-build -c Debug +dotnet run --project tests/Terminal.Gui.Cli.SmokeTests --no-build -c Debug +``` + +## Code style verification (must pass before committing) + +CI runs JetBrains ReSharper cleanup (`dotnet jb cleanupcode`) followed by +`dotnet format` and checks for a clean `git diff`. To replicate locally: + +```bash +dotnet tool restore +dotnet jb cleanupcode Terminal.Gui.Cli.slnx --no-build --verbosity=WARN +dotnet format Terminal.Gui.Cli.slnx --no-restore +git diff --exit-code # must produce no output +``` + +Run these commands **before committing** to avoid CI failures. ## Coding standards @@ -39,4 +55,6 @@ If guidance conflicts, follow `specs/constitution.md`. - Use Allman braces and always include braces for conditionals/loops. - Prefer guard clauses / early returns to deep nesting. - One type per file for non-trivial public/internal types. +- **Use target-typed `new()`** when the type is clear from context (ReSharper enforces this). +- **Do not add redundant `using` directives** — if a namespace matches the file's own namespace prefix, it's already in scope. ReSharper will remove these. - Do not add unrelated implementation while scaffolding.