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