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/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. 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/examples/Terminal.Gui.Cli.ExampleApp/GreetCommand.cs b/examples/Terminal.Gui.Cli.ExampleApp/GreetCommand.cs new file mode 100644 index 0000000..f0308ca --- /dev/null +++ b/examples/Terminal.Gui.Cli.ExampleApp/GreetCommand.cs @@ -0,0 +1,46 @@ +using Terminal.Gui.App; + +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 ("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..b9331d2 --- /dev/null +++ b/examples/Terminal.Gui.Cli.ExampleApp/InfoCommand.cs @@ -0,0 +1,57 @@ +using Terminal.Gui.App; + +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/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..533c50a --- /dev/null +++ b/src/Terminal.Gui.Cli/AgentGuideCommand.cs @@ -0,0 +1,49 @@ +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..da84d7e --- /dev/null +++ b/src/Terminal.Gui.Cli/ArgParser.cs @@ -0,0 +1,455 @@ +using System.Globalization; + +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; + + /// 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 }; + } + + var 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 }; + } + + Dictionary commandOptions = new (StringComparer.OrdinalIgnoreCase); + Dictionary> extensionValues = new (StringComparer.OrdinalIgnoreCase); + List arguments = new (); + string? alias = null; + string? initial = null; + string? title = null; + var json = false; + TimeSpan? timeout = null; + var fullscreen = false; + var cat = false; + string? outputPath = null; + int? rows = null; + + while (index < args.Length) + { + var 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 var 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 var extensionError)) + { + return ParseResult.Fail (extensionError ?? $"Invalid option '{token}'."); + } + + continue; + } + + if (command is not null && + TryFindCommandOption (command, token, out CommandOptionDescriptor? commandOption)) + { + var isFlag = commandOption!.ValueType == typeof (bool); + + if (!AddCommandOptionValue (args, ref index, token, commandOption.Name, isFlag, commandOptions, + out var commandError)) + { + return ParseResult.Fail (commandError ?? $"Invalid option '{token}'."); + } + + 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}'."); + } + + 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."); + } + } + + Dictionary> extensions = extensionValues.ToDictionary ( + static pair => pair.Key, + static pair => (IReadOnlyList)pair.Value, + StringComparer.OrdinalIgnoreCase); + + CommandRunOptions options = new () + { + 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; + } + + 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 var value) + || !double.IsFinite (value) + || value < 0) + { + return false; + } + + try + { + 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; + } + + 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 var 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 var rowsText, out error)) + { + return true; + } + + if (!int.TryParse (rowsText, NumberStyles.None, CultureInfo.InvariantCulture, out var 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 }; + } + } +} diff --git a/src/Terminal.Gui.Cli/CliHost.cs b/src/Terminal.Gui.Cli/CliHost.cs new file mode 100644 index 0000000..018afe0 --- /dev/null +++ b/src/Terminal.Gui.Cli/CliHost.cs @@ -0,0 +1,197 @@ +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 IHelpProvider _helpProvider; + private readonly CliHostOptions _options; + private readonly ArgParser _parser; + + /// 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; + + try + { + catResult = await viewer.RenderCatAsync (runOptions, stdout, effectiveToken); + } + catch (OperationCanceledException) + { + return ExitCodes.Cancelled; + } + + if (catResult is not null) + { + return ExitCodes.FromResult (catResult.Value); + } + } + + CommandResult result; + + try + { + result = await RunWithTerminalGuiAsync (command, runOptions, effectiveToken); + } + catch (OperationCanceledException) + { + result = CreateCancelledResult (); + } + + if (!ResultWriter.Write (result, runOptions.JsonOutput, stdout, stderr, runOptions.OutputPath)) + { + return ExitCodes.UsageError; + } + + return ExitCodes.FromResult (result); + } + + private static CommandResult CreateCancelledResult () + { + return new CommandResult ( + CommandStatus.Cancelled, + null, + null, + null); + } + + 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 StreamReader reader = new (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..a347336 --- /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 List _commands = []; + private readonly Dictionary _commandsByAlias = new (StringComparer.OrdinalIgnoreCase); + + /// + 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 (var 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 (var 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..f2b5904 --- /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 StreamReader reader = new (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..f5a5fba --- /dev/null +++ b/src/Terminal.Gui.Cli/HelpCommand.cs @@ -0,0 +1,68 @@ +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 IHelpProvider _helpProvider; + private readonly ICommandRegistry _registry; + + /// 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) + { + var 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..349108f --- /dev/null +++ b/src/Terminal.Gui.Cli/ICliCommand.cs @@ -0,0 +1,44 @@ +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) + { + return 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..f0fe8cf --- /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 +{ + async Task ICliCommand.RunAsync ( + IApplication app, + string? initial, + CommandRunOptions options, + CancellationToken cancellationToken) + { + CommandResult result = await RunAsync (app, initial, options, cancellationToken); + 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 new file mode 100644 index 0000000..1c52055 --- /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 +{ + /// 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. + /// + void Register (ICliCommand command); + + /// Resolves an alias case-insensitively. + bool TryResolve (string alias, out ICliCommand? command); +} 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..2cc7d73 --- /dev/null +++ b/src/Terminal.Gui.Cli/IViewerCommand.cs @@ -0,0 +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. +/// +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) + { + return 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..29ab182 --- /dev/null +++ b/src/Terminal.Gui.Cli/MarkdownRenderer.cs @@ -0,0 +1,18 @@ +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); + + 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 new file mode 100644 index 0000000..a546c04 --- /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); + + StringBuilder builder = new (); + 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); + + StringBuilder builder = new (); + builder.AppendLine ($"# {command.PrimaryAlias}"); + builder.AppendLine (command.Description); + + if (command.Options.Count > 0) + { + builder.AppendLine (); + builder.AppendLine ("Options:"); + + foreach (CommandOptionDescriptor option in command.Options) + { + var 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..86fa89b --- /dev/null +++ b/src/Terminal.Gui.Cli/OpenCliWriter.cs @@ -0,0 +1,170 @@ +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); + + StringBuilder builder = new (); + builder.Append ('{'); + AppendProperty (builder, "name", options.ApplicationName); + builder.Append (','); + AppendProperty (builder, "version", options.Version ?? "0.0.0"); + builder.Append (",\"commands\":["); + + var 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 (var 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 (var 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 (var 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) + { + StringBuilder builder = new (value.Length); + + foreach (var 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..1c73fd1 --- /dev/null +++ b/src/Terminal.Gui.Cli/ResultWriter.cs @@ -0,0 +1,69 @@ +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); + + 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) + { + 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..550502d --- /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, 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, true); + } + + private static string StripEscapes (string input, bool preserveSgr) + { + StringBuilder output = new (input.Length); + + for (var i = 0; i < input.Length; i++) + { + var c = input[i]; + + if (c != '\u001b') + { + output.Append (c); + continue; + } + + if (preserveSgr && TryReadSgr (input, i, out var 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; + } + + var 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; + } + + var introducer = input[start + 1]; + + if (introducer == '[') + { + for (var i = start + 2; i < input.Length; i++) + { + if (input[i] is >= '@' and <= '~') + { + return i; + } + } + + return input.Length - 1; + } + + if (introducer == ']') + { + for (var 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..63d3e18 --- /dev/null +++ b/src/Terminal.Gui.Cli/TypeNames.cs @@ -0,0 +1,51 @@ +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..05a8750 --- /dev/null +++ b/tests/Terminal.Gui.Cli.Tests/ArgParserTests.cs @@ -0,0 +1,75 @@ +using Terminal.Gui.App; +using Xunit; + +namespace Terminal.Gui.Cli.Tests; + +public sealed class ArgParserTests +{ + [Fact] + public void Parse_SeparatesFrameworkGlobalsCommandOptionsAndPositionals () + { + 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); + + 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 TryParseTimeout_WithOverflowValue_ReturnsFalse () + { + Assert.False (ArgParser.TryParseTimeout ("1e999999h", out _)); + } + + [Fact] + public void Parse_RejectsMissingRequiredCommandOption () + { + ArgParser parser = new ([]); + + ArgParser.ParseResult result = parser.Parse (["pick"], new TestCommand (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..ae35150 --- /dev/null +++ b/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs @@ -0,0 +1,89 @@ +using Terminal.Gui.App; +using Xunit; + +namespace Terminal.Gui.Cli.Tests; + +public sealed class CliHostTests +{ + [Fact] + public async Task RunAsync_OpenCli_WritesRegisteredBuiltIns () + { + CliHost host = new (options => + { + options.ApplicationName = "sample"; + options.Version = "1.2.3"; + }); + using StringWriter stdout = new (); + using StringWriter stderr = new (); + + var 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 () + { + CliHost host = new (options => + { + options.AgentGuide = "# Guide"; + options.AgentGuideIsResource = false; + }); + using StringWriter stdout = new (); + using StringWriter stderr = new (); + + var 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 ()); + } + + [Fact] + public async Task RunAsync_CommandCancellation_ReturnsCancelledExitCode () + { + CliHost host = new (); + host.Registry.Register (new CancellingCatCommand ()); + using StringWriter stdout = new (); + using StringWriter stderr = new (); + using CancellationTokenSource cancellation = new (); + cancellation.Cancel (); + + var exitCode = await host.RunAsync (["cancel", "--cat"], cancellation.Token, 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); + } + } +} diff --git a/tests/Terminal.Gui.Cli.Tests/CommandRegistryTests.cs b/tests/Terminal.Gui.Cli.Tests/CommandRegistryTests.cs new file mode 100644 index 0000000..f32f1d7 --- /dev/null +++ b/tests/Terminal.Gui.Cli.Tests/CommandRegistryTests.cs @@ -0,0 +1,50 @@ +using Terminal.Gui.App; +using Xunit; + +namespace Terminal.Gui.Cli.Tests; + +public sealed class CommandRegistryTests +{ + [Fact] + public void Register_ResolvesAliasesCaseInsensitively () + { + CommandRegistry registry = new (); + TestCommand command = new ("pick", ["pick", "select"]); + + registry.Register (command); + + Assert.True (registry.TryResolve ("SELECT", out ICliCommand? resolved)); + Assert.Same (command, resolved); + } + + [Fact] + public void Register_RejectsDuplicateAliasesCaseInsensitively () + { + CommandRegistry registry = new (); + 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..d4a88d0 --- /dev/null +++ b/tests/Terminal.Gui.Cli.Tests/OutputTests.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using Xunit; + +namespace Terminal.Gui.Cli.Tests; + +public sealed class OutputTests +{ + [Fact] + public void JsonEnvelope_ToJson_UsesCamelCaseAndOmitsNulls () + { + var 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 StringWriter stdout = new (); + using StringWriter stderr = new (); + + var 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")); + } +}