From 9daa2882b0d0cecfb9d4c1cc60caedc9eb7859d9 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Wed, 16 Sep 2026 11:50:03 -0700 Subject: [PATCH] feat: render clawctl help from the live command tree Help was the one clawctl surface that still looked like a different program: the library's renderer knows nothing about the heading, the grid, or the palette every other command uses. Render it ourselves instead. - Add ClawCtlHelp, which describes the command the user asked about by walking the parser: subcommands, options, arities, and descriptions. A command added to ClawCtlCommandLine is therefore documented without a matching edit here, which is the point of describing rather than listing. - Add ClawCtlHelpAction and install it on the root help option. The built-in HelpAction is sealed and exposes only a wrap width, so replacing the action is the supported extension point. One replacement covers every command, including commands added later, because the root's help option is recursive. - Render through ClawCtlConsole, so help inherits the identity mark, the label grid, and the colour policy, including --no-color. Three behaviours the library gave us for free and we now owe explicitly: hidden commands stay hidden, inherited recursive options are reported on subcommand help because they are part of the command line a user can type, and the non-recursive --version is not. Help never takes its name from RootCommand.Name, which defaults to the entry assembly and would read "testhost" under dotnet test. This is the same trap the version action already exists to avoid, and a test now covers it. Windows-style "/h" aliases are omitted. They still parse, but listing them widened the term column on every row to document a spelling nobody opens help to discover. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f472f437-3ee7-41f3-a501-81ae01590103 --- README.md | 8 +- docs/clawctl-output-style.md | 23 ++ src/OpenClaw.Launcher/ClawCtlCommandLine.cs | 19 +- src/OpenClaw.Launcher/ClawCtlConsole.cs | 95 +++++++-- src/OpenClaw.Launcher/ClawCtlHelp.cs | 198 ++++++++++++++++++ .../ClawCtlCommandLineTests.cs | 16 ++ .../ClawCtlHelpTests.cs | 167 +++++++++++++++ 7 files changed, 504 insertions(+), 22 deletions(-) create mode 100644 src/OpenClaw.Launcher/ClawCtlHelp.cs create mode 100644 tests/OpenClaw.Launcher.Tests/ClawCtlHelpTests.cs diff --git a/README.md b/README.md index 9a0f1c79..4e6597d1 100644 --- a/README.md +++ b/README.md @@ -91,9 +91,11 @@ the read-only application directory the workspace. | `clawctl --version` | Print the packaged launcher version. | Bare `clawctl`, `clawctl -h`, and `clawctl --help` print help without changing -state. `clawctl setup --help` prints help for that command alone. Help, usage, -and completion come from -[System.CommandLine](https://learn.microsoft.com/en-us/dotnet/standard/commandline/). +state. `clawctl setup --help` prints help for that command alone. Parsing, +usage errors, and completion come from +[System.CommandLine](https://learn.microsoft.com/en-us/dotnet/standard/commandline/), +while help is rendered by `clawctl` itself from the live command tree, so a +command added to the parser is documented without a separate help edit. Invalid management input is rejected with exit code `1` and a parse diagnostic on standard error; no readiness check runs. diff --git a/docs/clawctl-output-style.md b/docs/clawctl-output-style.md index c038ee48..2f8495dd 100644 --- a/docs/clawctl-output-style.md +++ b/docs/clawctl-output-style.md @@ -48,6 +48,29 @@ commands should not expose individual log paths when the bundle can collect the same evidence. Its final bundle path is emitted as an exact standalone line, outside a width-constrained grid, so it remains copyable. +## Help + +Help is rendered by `clawctl`, not by System.CommandLine, so it uses the same +heading, grid, and palette as every other command. The built-in help action is +sealed and exposes only a wrap width, so replacing the action is the supported +extension point; the version action is replaced for a related reason. + +`ClawCtlHelp` describes the command the user asked about by walking the live +tree, so a command added to `ClawCtlCommandLine` appears in help with no edit +here. Three obligations come with that: + +- Give every command and option a description. An entry without one renders as + a blank column. +- Honor `Hidden`. The library's renderer filters hidden symbols and so must + this one. +- Report inherited recursive options. `--json` and `--no-color` are declared + once on the root and apply to every command, so subcommand help lists them; + `--version` is not recursive and must not appear. + +Never take the command name from `RootCommand.Name`. It defaults to the entry +assembly, which is the test host under `dotnet test` and the scenario driver +under the NativeAOT suite. Use the known control command name instead. + ## Color Output is composed from Spectre.Console renderables — a `Grid` for diff --git a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs index 251df217..073f65c4 100644 --- a/src/OpenClaw.Launcher/ClawCtlCommandLine.cs +++ b/src/OpenClaw.Launcher/ClawCtlCommandLine.cs @@ -201,16 +201,27 @@ public static RootCommand Create( // Bare `clawctl` is a discovery request, not a usage error, so the root // prints help and succeeds instead of reporting a missing command. - root.SetAction((parseResult, _) => Task.FromResult(WriteHelp(parseResult))); + var helpAction = new ClawCtlHelpAction(noColor); + root.SetAction((parseResult, _) => Task.FromResult(helpAction.Invoke(parseResult))); UseLauncherVersion(root); + UseClawCtlHelp(root, helpAction); return root; } - private static int WriteHelp(ParseResult parseResult) + // The built-in help action is sealed and exposes only a wrap width, so + // replacing it is the supported way to render help. One replacement covers + // every command: the root's help option is recursive, so a command added + // later reaches the same action and is described from the live tree. + private static void UseClawCtlHelp(RootCommand root, ClawCtlHelpAction helpAction) { - HelpAction help = new(); - return help.Invoke(parseResult); + foreach (Option option in root.Options) + { + if (option is HelpOption helpOption) + { + helpOption.Action = helpAction; + } + } } // The built-in version action reports the entry assembly, which is the test diff --git a/src/OpenClaw.Launcher/ClawCtlConsole.cs b/src/OpenClaw.Launcher/ClawCtlConsole.cs index 4d3b3cac..77aa19d5 100644 --- a/src/OpenClaw.Launcher/ClawCtlConsole.cs +++ b/src/OpenClaw.Launcher/ClawCtlConsole.cs @@ -77,6 +77,55 @@ internal static void WriteResult( } } + internal static void WriteHelp( + TextWriter output, + ClawCtlHelpModel model, + bool useColor = false) + { + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(model); + + var view = new ResultView( + SupportsUnicode(output), + model.CommandPath, + ResolveWidth(output)); + + if (!string.IsNullOrWhiteSpace(model.Description)) + { + foreach (string paragraph in model.Description.Split( + $"{Environment.NewLine}{Environment.NewLine}", + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + view.Line(paragraph); + view.Blank(); + } + } + + view.Row("Usage", new Text(model.Usage)); + + if (model.Commands.Count > 0) + { + view.Blank(); + view.Line("Commands"); + foreach (ClawCtlHelpEntry entry in model.Commands) + { + view.Term(entry.Term, entry.Description); + } + } + + if (model.Options.Count > 0) + { + view.Blank(); + view.Line("Options"); + foreach (ClawCtlHelpEntry entry in model.Options) + { + view.Term(entry.Term, entry.Description); + } + } + + Render(output, view.Build(), useColor, view.Unicode); + } + internal static void WriteUnexpectedFailure( TextWriter error, string command, @@ -634,7 +683,15 @@ private sealed class ResultView(bool unicode, string command, int width) public bool Unicode { get; } = unicode; public void Row(string label, IRenderable value) => - _items.Add(Item.ForRow(label, value)); + _items.Add(Item.ForRow($"{label}:", new Text($"{label}:"), value)); + + // A help term is something the user types, so it takes the same accent + // as a next-action command rather than a label's plain foreground. + public void Term(string term, string? description) => + _items.Add(Item.ForRow( + term, + new Text(term, AccentStyle), + description is null ? Text.Empty : new Text(description))); public void Command(string label, string command) => Row(label, new Text(command, AccentStyle)); @@ -645,7 +702,10 @@ public void Detail(string detail) ['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { - _items.Add(Item.ForRow(null, new Text(LowercaseFirst(line), MutedStyle))); + _items.Add(Item.ForRow( + string.Empty, + Text.Empty, + new Text(LowercaseFirst(line), MutedStyle))); } } @@ -675,9 +735,9 @@ public Rows Build() int labelWidth = 0; foreach (Item item in _items) { - if (item.Label is not null) + if (item.ColumnText is not null) { - labelWidth = Math.Max(labelWidth, item.Label.Length + 1); + labelWidth = Math.Max(labelWidth, item.ColumnText.Length + 1); } } @@ -700,9 +760,7 @@ public Rows Build() blocks.Add(grid); } - grid.AddRow( - item.Label is null ? Text.Empty : new Text($"{item.Label}:"), - item.Value!); + grid.AddRow(item.Column!, item.Value!); } var heading = new Paragraph(); @@ -711,8 +769,12 @@ public Rows Build() heading.Append($"{IdentityMark} "); } - heading.Append("clawctl ", MutedStyle); - heading.Append(command, AccentStyle); + heading.Append(HostEntrypointResolver.ControlCommandName, MutedStyle); + if (!string.IsNullOrEmpty(command)) + { + heading.Append(" "); + heading.Append(command, AccentStyle); + } return new Rows( heading, @@ -722,23 +784,26 @@ public Rows Build() private readonly struct Item { - private Item(string? label, IRenderable? value, IRenderable? free) + private Item(string? columnText, IRenderable? column, IRenderable? value, IRenderable? free) { - Label = label; + ColumnText = columnText; + Column = column; Value = value; Free = free; } - public string? Label { get; } + public string? ColumnText { get; } + + public IRenderable? Column { get; } public IRenderable? Value { get; } public IRenderable? Free { get; } - public static Item ForRow(string? label, IRenderable value) => - new(label, value, null); + public static Item ForRow(string columnText, IRenderable column, IRenderable value) => + new(columnText, column, value, null); - public static Item ForFree(IRenderable free) => new(null, null, free); + public static Item ForFree(IRenderable free) => new(null, null, null, free); } } } diff --git a/src/OpenClaw.Launcher/ClawCtlHelp.cs b/src/OpenClaw.Launcher/ClawCtlHelp.cs new file mode 100644 index 00000000..86a61032 --- /dev/null +++ b/src/OpenClaw.Launcher/ClawCtlHelp.cs @@ -0,0 +1,198 @@ +using System.CommandLine; +using System.CommandLine.Invocation; + +namespace OpenClaw.Launcher; + +internal sealed record ClawCtlHelpEntry(string Term, string? Description); + +internal sealed record ClawCtlHelpModel( + string CommandPath, + string? Description, + string Usage, + IReadOnlyList Commands, + IReadOnlyList Options); + +// Help is described from the live command tree rather than a hand-maintained +// list, so a command added to ClawCtlCommandLine appears in help without an +// edit here. The only thing a new command must supply is a description. +internal static class ClawCtlHelp +{ + internal static ClawCtlHelpModel Describe(Command command) + { + ArgumentNullException.ThrowIfNull(command); + + string relativePath = BuildRelativePath(command); + List commands = []; + foreach (Command subcommand in command.Subcommands) + { + if (!subcommand.Hidden) + { + commands.Add(new ClawCtlHelpEntry(subcommand.Name, subcommand.Description)); + } + } + + List options = CollectOptions(command); + string invocation = relativePath.Length == 0 + ? HostEntrypointResolver.ControlCommandName + : $"{HostEntrypointResolver.ControlCommandName} {relativePath}"; + string usage = commands.Count > 0 + ? $"{invocation} [options]" + : $"{invocation} [options]"; + + return new ClawCtlHelpModel( + relativePath, + command.Description, + usage, + commands, + options); + } + + // A command's own options, then the recursive options it inherits. The + // inherited ones are real: --json and --no-color are declared on the root + // and apply to every command, so omitting them would under-report the + // command line the user can actually type. + private static List CollectOptions(Command command) + { + List entries = []; + HashSet seen = new(StringComparer.Ordinal); + + foreach (Option option in command.Options) + { + AddOption(entries, seen, option); + } + + foreach (Command ancestor in Ancestors(command)) + { + foreach (Option option in ancestor.Options) + { + if (option.Recursive) + { + AddOption(entries, seen, option); + } + } + } + + return entries; + } + + private static void AddOption( + List entries, + HashSet seen, + Option option) + { + if (option.Hidden || !seen.Add(option.Name)) + { + return; + } + + entries.Add(new ClawCtlHelpEntry(DescribeTerm(option), option.Description)); + } + + private static string DescribeTerm(Option option) + { + // Options carry Windows-style "/h" aliases as well as POSIX ones. + // Listing both widens the term column for every row to describe a + // spelling nobody reads help to discover, so only POSIX forms appear. + List forms = [option.Name]; + foreach (string alias in option.Aliases) + { + if (alias.StartsWith('-')) + { + forms.Add(alias); + } + } + + string term = string.Join(", ", forms); + bool takesValue = option.Arity.MaximumNumberOfValues > 0 && + option.ValueType != typeof(bool); + return takesValue + ? $"{term} <{option.HelpName ?? option.Name.TrimStart('-')}>" + : term; + } + + private static IEnumerable Ancestors(Command command) + { + foreach (Symbol parent in command.Parents) + { + if (parent is Command ancestor) + { + yield return ancestor; + foreach (Command further in Ancestors(ancestor)) + { + yield return further; + } + } + } + } + + // RootCommand names itself after the entry assembly, which is the test host + // or the scenario driver rather than the launcher, so the root contributes + // no name here and the caller supplies the real one. + private static string BuildRelativePath(Command command) + { + List names = []; + Command? current = command; + while (current is not null) + { + names.Insert(0, current.Name); + current = FirstParentCommand(current); + } + + return names.Count <= 1 ? string.Empty : string.Join(' ', names.Skip(1)); + } + + private static Command? FirstParentCommand(Command command) + { + foreach (Symbol parent in command.Parents) + { + if (parent is Command parentCommand) + { + return parentCommand; + } + } + + return null; + } +} + +// Replaces the built-in help action so help is rendered by the same console +// the rest of clawctl uses. The built-in action is sealed and exposes only a +// wrap width, so replacement is the supported extension point; the version +// option is customised the same way and for a related reason. +internal sealed class ClawCtlHelpAction(Option noColor) : SynchronousCommandLineAction +{ + public override int Invoke(ParseResult parseResult) + { + ArgumentNullException.ThrowIfNull(parseResult); + + ClawCtlHelpModel model = ClawCtlHelp.Describe(parseResult.CommandResult.Command); + TextWriter output = parseResult.InvocationConfiguration.Output; + bool outputIsProcessConsoleWriter = ReferenceEquals(output, Console.Out); + IDisposable? restore = null; + bool noColorValue; + try + { + noColorValue = parseResult.GetValue(noColor); + } + catch (InvalidOperationException) + { + noColorValue = true; + } + + bool useColor = ClawCtlColorPolicy.PrepareOutput( + noColorValue, + json: false, + outputIsProcessConsoleWriter, + WindowsHostConsole.Instance.IsInteractive, + Environment.GetEnvironmentVariable, + () => WindowsHostConsole.Instance + .TryEnableVirtualTerminalProcessing(output, _ => { }, out restore)); + + using (restore) + { + ClawCtlConsole.WriteHelp(output, model, useColor); + } + + return 0; + } +} diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs index 90449653..c2a446a5 100644 --- a/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlCommandLineTests.cs @@ -30,6 +30,22 @@ private static string Normalize(string text) => ' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + [Theory] + [InlineData(1, "status", "--no-color=invalid")] + [InlineData(0, "status", "--help", "--no-color=invalid")] + public async Task MalformedNoColorStillRendersOrdinaryHelp( + int expectedExitCode, + params string[] args) + { + (int exitCode, string output, string error) = + await RunAsync(args).ConfigureAwait(true); + + Assert.Equal(expectedExitCode, exitCode); + Assert.Contains("Usage:", output, StringComparison.Ordinal); + Assert.DoesNotContain("This shouldn't happen", error, StringComparison.Ordinal); + Assert.DoesNotContain("\u001b[", output, StringComparison.Ordinal); + } + [Theory] [InlineData()] [InlineData("--help")] diff --git a/tests/OpenClaw.Launcher.Tests/ClawCtlHelpTests.cs b/tests/OpenClaw.Launcher.Tests/ClawCtlHelpTests.cs new file mode 100644 index 00000000..0baf097b --- /dev/null +++ b/tests/OpenClaw.Launcher.Tests/ClawCtlHelpTests.cs @@ -0,0 +1,167 @@ +using System.CommandLine; +using System.CommandLine.Help; +using System.Text.RegularExpressions; + +namespace OpenClaw.Launcher.Tests; + +public sealed class ClawCtlHelpTests +{ + private static ClawCtlHandlers NoopHandlers() => new() + { + Setup = (_, _) => Task.FromResult(0), + Status = _ => Task.FromResult(0), + CollectLogs = (_, _) => Task.FromResult(0), + Teardown = (_, _) => Task.FromResult(0), + PowerShell = _ => Task.FromResult(0), + GatewayStart = _ => Task.FromResult(0), + GatewayStatus = _ => Task.FromResult(0), + GatewayStop = _ => Task.FromResult(0) + }; + + private static ClawCtlHelpModel Describe(string commandPath) + { + RootCommand root = ClawCtlCommandLine.Create(NoopHandlers()); + ParseResult parsed = root.Parse(commandPath); + return ClawCtlHelp.Describe(parsed.CommandResult.Command); + } + + // The point of describing help from the live command tree: a command added + // to the parser is documented without anyone editing the help renderer. + [Fact] + public void ACommandAddedToTheTreeIsDescribedWithoutTouchingHelp() + { + RootCommand root = ClawCtlCommandLine.Create(NoopHandlers()); + root.Subcommands.Add(new Command("foo", "Do the foo thing.")); + + ClawCtlHelpModel model = ClawCtlHelp.Describe(root); + + ClawCtlHelpEntry entry = Assert.Single(model.Commands, c => c.Term == "foo"); + Assert.Equal("Do the foo thing.", entry.Description); + } + + [Fact] + public void HiddenCommandsAreNotDescribed() + { + RootCommand root = ClawCtlCommandLine.Create(NoopHandlers()); + root.Subcommands.Add(new Command("secret", "Internal.") { Hidden = true }); + + ClawCtlHelpModel model = ClawCtlHelp.Describe(root); + + Assert.DoesNotContain(model.Commands, c => c.Term == "secret"); + } + + // RootCommand names itself after the entry assembly, so an unguarded + // renderer reports the test host here and the scenario driver in the + // NativeAOT suite. Usage must always name the shipped command. + [Fact] + public void RootUsageNamesClawCtlRatherThanTheEntryAssembly() + { + ClawCtlHelpModel model = Describe(string.Empty); + + Assert.Equal(string.Empty, model.CommandPath); + Assert.StartsWith("clawctl ", model.Usage, StringComparison.Ordinal); + Assert.DoesNotContain("testhost", model.Usage, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void SubcommandUsageIncludesTheCommandPath() + { + ClawCtlHelpModel model = Describe("setup"); + + Assert.Equal("setup", model.CommandPath); + Assert.Equal("clawctl setup [options]", model.Usage); + } + + [Fact] + public void NestedSubcommandUsageIncludesEveryLevel() + { + ClawCtlHelpModel model = Describe("gateway-service start"); + + Assert.Equal("gateway-service start", model.CommandPath); + Assert.Equal("clawctl gateway-service start [options]", model.Usage); + } + + // --json and --no-color are declared once on the root as recursive options. + // They are part of the command line a user can type for a subcommand, so + // subcommand help has to report them. + [Fact] + public void SubcommandHelpReportsInheritedRecursiveOptions() + { + ClawCtlHelpModel model = Describe("setup"); + string[] terms = [.. model.Options.Select(o => o.Term)]; + + Assert.Contains(terms, t => t.StartsWith("--fresh", StringComparison.Ordinal)); + Assert.Contains(terms, t => t.StartsWith("--json", StringComparison.Ordinal)); + Assert.Contains(terms, t => t.StartsWith("--no-color", StringComparison.Ordinal)); + } + + // --version is deliberately not recursive; reporting it on a subcommand + // would describe a command line that does not parse. + [Fact] + public void SubcommandHelpOmitsNonRecursiveRootOptions() + { + ClawCtlHelpModel model = Describe("setup"); + + Assert.DoesNotContain( + model.Options, + o => o.Term.StartsWith("--version", StringComparison.Ordinal)); + } + + [Fact] + public void OptionsThatTakeAValueDescribeTheValue() + { + ClawCtlHelpModel model = Describe("collect-logs"); + + ClawCtlHelpEntry output = Assert.Single( + model.Options, + o => o.Term.StartsWith("--output", StringComparison.Ordinal)); + Assert.Contains("<", output.Term, StringComparison.Ordinal); + } + + // Windows-style aliases still parse; listing them widens the term column + // on every row for a spelling nobody reads help to discover. + [Fact] + public void HelpTermsOmitWindowsStyleAliases() + { + ClawCtlHelpModel model = Describe(string.Empty); + + ClawCtlHelpEntry help = Assert.Single( + model.Options, + o => o.Term.StartsWith("--help", StringComparison.Ordinal)); + Assert.DoesNotContain("/", help.Term, StringComparison.Ordinal); + Assert.Contains("-h", help.Term, StringComparison.Ordinal); + } + + [Fact] + public void ColorChangesOnlyTerminalFormatting() + { + ClawCtlHelpModel model = Describe(string.Empty); + using var plain = new StringWriter(); + using var colored = new StringWriter(); + + ClawCtlConsole.WriteHelp(plain, model); + ClawCtlConsole.WriteHelp(colored, model, useColor: true); + + Assert.Contains("\u001b[", colored.ToString(), StringComparison.Ordinal); + Assert.Equal( + plain.ToString(), + Regex.Replace(colored.ToString(), "\u001b\\[[0-9;]*m", string.Empty)); + } + + // The replacement has to reach every command, including ones added after + // the tree was built, or help silently reverts to the library renderer. + [Fact] + public void TheClawCtlHelpActionIsUsedByEveryCommand() + { + RootCommand root = ClawCtlCommandLine.Create(NoopHandlers()); + root.Subcommands.Add(new Command("foo", "Do the foo thing.")); + + Assert.IsType(root.Parse("--help").Action); + Assert.IsType(root.Parse("setup --help").Action); + Assert.IsType(root.Parse("gateway-service start --help").Action); + Assert.IsType(root.Parse("foo --help").Action); + Assert.DoesNotContain( + root.Options.OfType(), + option => option.Action is HelpAction); + } +}