diff --git a/README.md b/README.md index c54e767..3fec970 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,35 @@ # Terminal.Gui.Cli -![Terminal.Gui.Cli Example App](docs/images/hero.gif) +[![NuGet](https://img.shields.io/nuget/vpre/Terminal.Gui.Cli)](https://www.nuget.org/packages/Terminal.Gui.Cli) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -A .NET library that lets [Terminal.Gui](https://github.com/gui-cs/Terminal.Gui) applications expose Views as scriptable CLI commands with typed JSON output, POSIX exit codes, and AI-agent discoverability. +> A .NET library that turns [Terminal.Gui](https://github.com/gui-cs/Terminal.Gui) apps into scriptable CLI tools — with typed JSON output, POSIX exit codes, and built-in AI-agent discoverability. -Ships as a single NuGet package: **[`Terminal.Gui.Cli`](https://www.nuget.org/packages/Terminal.Gui.Cli)**. +![Terminal.Gui.Cli in action](docs/images/hero.gif) -## What it does +## Why -`Terminal.Gui.Cli` provides a hosting layer (`CliHost`) that wires up: +Terminal.Gui gives you rich TUI applications. **Terminal.Gui.Cli** lets those same apps participate in scripts, pipelines, and agentic workflows — no separate CLI layer needed. -- **CLI parsing** — positional command dispatch, typed options, `--initial` pre-fill for input commands. -- **Structured output** — `--json` emits a versioned `JsonEnvelope`; `--cat` renders viewer content headlessly. -- **AI-agent discoverability** — `--opencli` emits machine-readable metadata; `--agent-guide` serves embedded Markdown guidance. -- **Built-in help** — `--help` renders command/option metadata via pluggable `IHelpProvider`. -- **Exit codes** — deterministic POSIX exit codes from `CommandResult` status. +One NuGet package. One `CliHost`. All your views become commands. -## Command model +## Features -| Kind | Interface | Description | -|------|-----------|-------------| -| **Input** | `ICliCommand` | Launches a Terminal.Gui UI, returns a typed result. | -| **Viewer** | `IViewerCommand` | Displays content; supports `--cat` for headless rendering. | - -Commands register explicitly (no reflection scanning) and resolve by case-insensitive alias. +| Capability | How | +|---|---| +| **CLI parsing** | Positional command dispatch, typed options, `--initial` pre-fill | +| **Structured output** | `--json` emits a versioned `JsonEnvelope` | +| **Headless rendering** | `--cat` renders viewer content without a TUI | +| **AI discoverability** | `--opencli` metadata + `agent-guide` embedded Markdown | +| **Built-in help** | `--help` via pluggable `IHelpProvider` | +| **Exit codes** | Deterministic POSIX codes from `CommandResult` | ## Quickstart +```sh +dotnet add package Terminal.Gui.Cli +``` + ```csharp using Terminal.Gui.Cli; @@ -36,29 +39,33 @@ CliHost host = new (options => options.Version = "1.0.0"; }); -host.Registry.Register (new MyCommand ()); +host.Registry.Register (new GreetCommand ()); return await host.RunAsync (args); ``` +Then run it: + ```sh -# Interactive (launches Terminal.Gui) -my-app greet --initial "World" +my-app greet --initial "World" # interactive TUI +my-app greet --initial "World" --json # → {"schemaVersion":1,"status":"ok","value":"Hello, World!"} +my-app info --cat # headless viewer output +my-app --opencli # machine-readable command metadata +my-app agent-guide # embedded agent guidance (Markdown) +``` -# JSON envelope -my-app greet --initial "World" --json +## Command model -# Agent discovery -my-app --opencli -my-app agent-guide +| Kind | Interface | Description | +|------|-----------|-------------| +| **Input** | `ICliCommand` | Launches a Terminal.Gui UI, returns a typed result | +| **Viewer** | `IViewerCommand` | Displays content; supports `--cat` for headless rendering | -# Headless viewer -my-app info --cat -``` +Commands register explicitly (no reflection scanning) and resolve by case-insensitive alias. -## Framework options +## Global options -All commands inherit these options from the host: +Every command inherits these from the host: | Option | Description | |--------|-------------| @@ -66,7 +73,7 @@ All commands inherit these options from the host: | `--version` | Show version | | `--opencli` | Emit OpenCLI metadata JSON | | `--json` | Wrap output in JSON envelope | -| `--initial ` | Pre-fill input value | +| `--initial ` | Pre-fill input value (non-interactive mode) | | `--timeout ` | Cancel after duration (e.g., `30s`, `5m`) | | `--output ` / `-o` | Write output to file | | `--cat` | Headless render (viewer commands only) | @@ -74,35 +81,39 @@ All commands inherit these options from the host: ## Repository layout ``` -specs/ Constitution and library spec src/ Terminal.Gui.Cli library tests/ Unit, integration, and smoke tests -examples/ Example console app +examples/ Example console app (see hero GIF above) +specs/ Constitution and library specification scripts/ Tooling and recording scripts docs/ Images and documentation assets ``` -## Build +## Building from source -Requires .NET 10 SDK. Solution file: `Terminal.Gui.Cli.slnx`. +Requires **.NET 10 SDK**. Solution: `Terminal.Gui.Cli.slnx`. ```sh dotnet restore Terminal.Gui.Cli.slnx dotnet build Terminal.Gui.Cli.slnx -# Tests +# Run all test tiers dotnet run --project tests/Terminal.Gui.Cli.Tests dotnet run --project tests/Terminal.Gui.Cli.IntegrationTests dotnet run --project tests/Terminal.Gui.Cli.SmokeTests -# Example app +# Try the example app dotnet run --project examples/Terminal.Gui.Cli.ExampleApp -- greet --initial "World" --json ``` ## Status -**Alpha** — `0.1.0-develop` pre-release stream on the `develop` branch. +**Alpha** — `0.1.0-develop` pre-release. API surface is stabilizing; breaking changes possible. + +## Contributing + +See [`specs/constitution.md`](specs/constitution.md) for architectural rules and PR requirements. ## License -MIT; see [`LICENSE`](LICENSE). +MIT — see [`LICENSE`](LICENSE). diff --git a/docs/images/hero.gif b/docs/images/hero.gif index da61867..a3670fa 100644 Binary files a/docs/images/hero.gif and b/docs/images/hero.gif differ diff --git a/tests/Terminal.Gui.Cli.IntegrationTests/HelpCommandIntegrationTests.cs b/tests/Terminal.Gui.Cli.IntegrationTests/HelpCommandIntegrationTests.cs new file mode 100644 index 0000000..85c69b5 --- /dev/null +++ b/tests/Terminal.Gui.Cli.IntegrationTests/HelpCommandIntegrationTests.cs @@ -0,0 +1,152 @@ +using Terminal.Gui.App; +using Terminal.Gui.Drivers; +using Xunit; + +namespace Terminal.Gui.Cli.IntegrationTests; + +public sealed class HelpCommandIntegrationTests +{ + [Fact] + public async Task RunAsync_WithStopAfterFirstIteration_RendersMarkdownInViewer () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.StopAfterFirstIteration = true; + + CommandRegistry registry = new (); + MetadataHelpProvider helpProvider = new (); + HelpCommand helpCommand = new (registry, helpProvider); + registry.Register (helpCommand); + + CommandRunOptions options = new (); + + CommandResult result = await helpCommand.RunAsync (app, null, options, CancellationToken.None); + + Assert.Equal (CommandStatus.Ok, result.Status); + } + + [Fact] + public async Task RunAsync_CancellationToken_AlreadyCancelled_DoesNotHang () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + CommandRegistry registry = new (); + MetadataHelpProvider helpProvider = new (); + HelpCommand helpCommand = new (registry, helpProvider); + registry.Register (helpCommand); + + CommandRunOptions options = new (); + + using CancellationTokenSource cts = new (); + await cts.CancelAsync (); + + // Should either throw OperationCanceledException or return quickly + try + { + await helpCommand.RunAsync (app, null, options, cts.Token); + } + catch (OperationCanceledException) + { + // Expected + } + } + + [Fact] + public async Task RunAsync_RendersHelpText_ContainingCommandName () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.StopAfterFirstIteration = true; + + // Set screen size for deterministic rendering + app.Driver!.SetScreenSize (80, 24); + + CommandRegistry registry = new (); + MetadataHelpProvider helpProvider = new (); + HelpCommand helpCommand = new (registry, helpProvider); + registry.Register (helpCommand); + + CommandRunOptions options = new (); + + CommandResult result = await helpCommand.RunAsync (app, null, options, CancellationToken.None); + + Assert.Equal (CommandStatus.Ok, result.Status); + + // Verify the driver rendered content containing the "help" command + var driverContents = app.Driver.ToString (); + Assert.Contains ("help", driverContents); + } + + [Fact] + public async Task RunAsync_WithSubcommandArgument_RendersCommandHelp () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.StopAfterFirstIteration = true; + + app.Driver!.SetScreenSize (80, 24); + + CommandRegistry registry = new (); + MetadataHelpProvider helpProvider = new (); + HelpCommand helpCommand = new (registry, helpProvider); + registry.Register (helpCommand); + registry.Register (new StubCommand ("greet", "Say hello.")); + + CommandRunOptions options = new () + { + Arguments = ["greet"] + }; + + CommandResult result = await helpCommand.RunAsync (app, null, options, CancellationToken.None); + + Assert.Equal (CommandStatus.Ok, result.Status); + + var driverContents = app.Driver.ToString (); + Assert.Contains ("greet", driverContents); + } + + [Fact] + public async Task RenderCatAsync_ProducesAnsiOutput () + { + CommandRegistry registry = new (); + MetadataHelpProvider helpProvider = new (); + HelpCommand helpCommand = new (registry, helpProvider); + registry.Register (helpCommand); + + CommandRunOptions options = new (); + using StringWriter stdout = new (); + + CommandResult? result = await helpCommand.RenderCatAsync (options, stdout, CancellationToken.None); + + Assert.NotNull (result); + Assert.Equal (CommandStatus.Ok, result.Value.Status); + + var output = stdout.ToString (); + + // ANSI escape sequences present + Assert.Contains ("\x1b[", output); + Assert.Contains ("help", output); + } + + private sealed class StubCommand (string alias, string description) : ICliCommand + { + public string PrimaryAlias { get; } = alias; + + public IReadOnlyList Aliases => [PrimaryAlias]; + + public string Description => description; + + 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/CliHostTests.cs b/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs index 646c20c..dfb1791 100644 --- a/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs +++ b/tests/Terminal.Gui.Cli.Tests/CliHostTests.cs @@ -43,29 +43,12 @@ public async Task RunAsync_AgentGuideCat_WritesLiteralWithoutStartingTui () 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 ()); - } - [Fact] public async Task RunAsync_HelpFlag_RendersMarkdownAsAnsi () { CliHost host = new (options => { - options.ApplicationName = "test-app"; + options.ApplicationName = "sample"; options.Version = "1.0.0"; }); using StringWriter stdout = new (); @@ -74,28 +57,52 @@ public async Task RunAsync_HelpFlag_RendersMarkdownAsAnsi () var exitCode = await host.RunAsync (["--help"], TestContext.Current.CancellationToken, stdout, stderr); Assert.Equal (ExitCodes.Ok, exitCode); + Assert.Equal (string.Empty, stderr.ToString ()); + var output = stdout.ToString (); - // MarkdownRenderer.RenderToAnsi produces ANSI escape sequences + + // ANSI escape sequences should be present (rendered markdown) Assert.Contains ("\x1b[", output); - Assert.Equal (string.Empty, stderr.ToString ()); + + // Should contain the command name from the registry + Assert.Contains ("help", output); } [Fact] public async Task RunAsync_HelpCat_RendersMarkdownAsAnsi () { - CliHost host = new (options => - { - options.ApplicationName = "test-app"; - options.Version = "1.0.0"; - }); + CliHost host = new (); using StringWriter stdout = new (); using StringWriter stderr = new (); var exitCode = await host.RunAsync (["help", "--cat"], TestContext.Current.CancellationToken, stdout, stderr); Assert.Equal (ExitCodes.Ok, exitCode); + Assert.Equal (string.Empty, stderr.ToString ()); + var output = stdout.ToString (); + + // ANSI escape sequences should be present (rendered markdown) Assert.Contains ("\x1b[", output); + + // Should contain the command name from the registry + Assert.Contains ("help", output); + } + + [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 ()); }