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"));
+ }
+}