Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
23 changes: 23 additions & 0 deletions docs/clawctl-output-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 15 additions & 4 deletions src/OpenClaw.Launcher/ClawCtlCommandLine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
95 changes: 80 additions & 15 deletions src/OpenClaw.Launcher/ClawCtlConsole.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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));
Expand All @@ -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)));
}
}

Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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();
Expand All @@ -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,
Expand All @@ -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);
}
}
}
Loading
Loading