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
1 change: 1 addition & 0 deletions changelog.d/+group-help-fix.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed group bare invocation showing "Unknown command" instead of group help, and help output now lists subcommands by name instead of raw argparse internals.
134 changes: 107 additions & 27 deletions src/milo/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import os
import sys
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, NoReturn

from milo._command_defs import (
Expand Down Expand Up @@ -41,6 +42,39 @@
]


# ---------------------------------------------------------------------------
# Resolve result types — discriminated union for command resolution
# ---------------------------------------------------------------------------


@dataclass(frozen=True, slots=True)
class ResolvedCommand:
"""A command was found and should be dispatched."""

command: CommandDef | LazyCommandDef
fmt: str


@dataclass(frozen=True, slots=True)
class ResolvedGroup:
"""A group was invoked without a subcommand — show its help."""

group: Group
fmt: str
prog: str = ""


@dataclass(frozen=True, slots=True)
class ResolvedNothing:
"""No command or group matched — may offer did-you-mean."""

attempted: str | None
fmt: str


ResolveResult = ResolvedCommand | ResolvedGroup | ResolvedNothing


class _MiloArgumentParser(argparse.ArgumentParser):
"""ArgumentParser subclass that provides did-you-mean suggestions."""

Expand Down Expand Up @@ -620,7 +654,7 @@ def _add_commands_to_subparsers(
"--format",
choices=["plain", "json", "table"],
default="plain",
help="Output format (default: plain)",
help="Output format",
)

def _add_groups_to_subparsers(
Expand Down Expand Up @@ -710,6 +744,7 @@ def _add_arguments_from_schema(
def run(self, argv: list[str] | None = None) -> Any:
"""Parse args and dispatch to the appropriate command."""
parser = self.build_parser()
self._parser = parser
args = parser.parse_args(argv)

# --completions mode
Expand Down Expand Up @@ -747,20 +782,26 @@ def run(self, argv: list[str] | None = None) -> Any:
ctx = self._build_context(args)

# Resolve command from args (may be nested in groups)
found, fmt = self._resolve_command_from_args(args)
if not found:
# Did-you-mean suggestion for typos
cmd_name = getattr(args, "_command", None)
if cmd_name:
suggestion = self.suggest_command(cmd_name)
result = self._resolve_command_from_args(args)

if isinstance(result, ResolvedGroup):
result.group.format_help(result.prog)
return None
Comment on lines +787 to +789
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For nested groups, group.format_help(self.name) only prefixes the CLI name, so bengal site config will render as bengal config in the help header. To show the correct invocation path, carry the resolved group path/prefix through _resolve_group_command (e.g., accumulate prog_prefix during recursion or store a full prog string on ResolvedGroup) and pass that into format_help.

Copilot uses AI. Check for mistakes.

if isinstance(result, ResolvedNothing):
if result.attempted:
suggestion = self.suggest_command(result.attempted)
if suggestion:
sys.stderr.write(
f"Unknown command: {cmd_name!r}. Did you mean {suggestion!r}?\n"
f"Unknown command: {result.attempted!r}. Did you mean {suggestion!r}?\n"
)
return None
parser.print_help()
self._format_root_help()
return None

found = result.command
fmt = result.fmt

# Resolve lazy commands
cmd = found.resolve() if isinstance(found, LazyCommandDef) else found

Expand Down Expand Up @@ -914,55 +955,94 @@ def _build_context(self, args: argparse.Namespace) -> Context:
globals=user_globals,
)

def _resolve_command_from_args(
self, args: argparse.Namespace
) -> tuple[CommandDef | LazyCommandDef | None, str]:
"""Walk the parsed args to find the leaf command."""
def _format_root_help(self) -> None:
"""Render root help from command/group registries and the actual parser."""
from milo.help import HelpState
from milo.templates import get_env

commands = tuple(
[
{"name": cmd.name, "help": getattr(cmd, "description", "")}
for cmd in self._commands.values()
if not getattr(cmd, "hidden", False)
]
+ [
{"name": g.name, "help": g.description}
for g in self._groups.values()
if not g.hidden
]
)

# Derive options from the actual parser so new flags are never missed
options: list[dict[str, Any]] = []
for action in self._parser._actions:
if isinstance(action, argparse._SubParsersAction):
continue
flags = ", ".join(action.option_strings) if action.option_strings else ""
if not flags:
continue
entry: dict[str, Any] = {"flags": flags, "help": action.help or ""}
if action.metavar:
entry["metavar"] = action.metavar
options.append(entry)

state = HelpState(
prog=self.name,
description=self.description,
commands=commands,
options=tuple(options),
)
env = get_env()
template = env.get_template("help.kida")
sys.stdout.write(template.render(state=state) + "\n")
sys.stdout.flush()

def _resolve_command_from_args(self, args: argparse.Namespace) -> ResolveResult:
"""Walk the parsed args to find the leaf command, group, or nothing."""
fmt = getattr(args, "format", "plain")

# Check top-level command
cmd_name = getattr(args, "_command", None)
if not cmd_name:
return None, fmt
return ResolvedNothing(attempted=None, fmt=fmt)

# Is it a direct command?
# Direct command?
cmd = self.get_command(cmd_name)
if cmd:
return cmd, fmt
return ResolvedCommand(command=cmd, fmt=fmt)

# Is it a group? Walk into it.
# Group? Walk into it.
group = self._groups.get(cmd_name)
if group is None:
resolved = self._group_alias_map.get(cmd_name)
if resolved:
group = self._groups.get(resolved)
if group is None:
return None, fmt
return ResolvedNothing(attempted=cmd_name, fmt=fmt)

return self._resolve_group_command(group, args, fmt)
return self._resolve_group_command(group, args, fmt, prog=self.name)

def _resolve_group_command(
self,
group: Group,
args: argparse.Namespace,
fmt: str,
) -> tuple[CommandDef | None, str]:
prog: str = "",
) -> ResolveResult:
"""Recursively resolve a command within a group from parsed args."""
group_prog = f"{prog} {group.name}".strip()
sub_name = getattr(args, f"_command_{group.name}", None)
if not sub_name:
return None, fmt
return ResolvedGroup(group=group, fmt=fmt, prog=group_prog)

# Check if it's a command in this group
cmd = group.get_command(sub_name)
if cmd:
return cmd, fmt
return ResolvedCommand(command=cmd, fmt=fmt)

# Check if it's a nested sub-group
sub_group = group.get_group(sub_name)
if sub_group:
return self._resolve_group_command(sub_group, args, fmt)
return self._resolve_group_command(sub_group, args, fmt, prog=group_prog)

return None, fmt
return ResolvedNothing(attempted=sub_name, fmt=fmt)

def call(self, command_name: str, **kwargs: Any) -> Any:
"""Programmatically call a command by name or dotted path.
Expand Down
32 changes: 32 additions & 0 deletions src/milo/groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,38 @@ def to_def(self) -> GroupDef:
hidden=self.hidden,
)

def format_help(self, prog_prefix: str = "") -> str:
"""Render help from this group's command/group registries.

Writes rendered help to stdout and returns the output string.
"""
import sys

from milo.help import HelpState
from milo.templates import get_env

prog = f"{prog_prefix} {self.name}".strip() if prog_prefix else self.name
commands = tuple(
[
{"name": cmd.name, "help": getattr(cmd, "description", "")}
for cmd in self._commands.values()
if not getattr(cmd, "hidden", False)
]
+ [
{"name": g.name, "help": g.description}
for g in self._groups.values()
if not g.hidden
]
)

state = HelpState(prog=prog, description=self.description, commands=commands)
env = get_env()
template = env.get_template("help.kida")
output = template.render(state=state)
sys.stdout.write(output + "\n")
sys.stdout.flush()
return output
Comment on lines +184 to +214
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring says format_help() "Does not print", but the implementation writes to sys.stdout and flushes. Either update the docstring to reflect the side effect, or change the method to only return the rendered string and let the caller decide where/how to output it (which also improves testability).

Copilot uses AI. Check for mistakes.

def walk_commands(self, prefix: str = ""):
"""Yield (dotted_path, CommandDef) for all commands in this tree."""
path_prefix = f"{prefix}{self.name}." if prefix else f"{self.name}."
Expand Down
2 changes: 2 additions & 0 deletions src/milo/help.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ class HelpState:
usage: str = ""
groups: tuple[dict[str, Any], ...] = ()
examples: tuple[dict[str, Any], ...] = ()
commands: tuple[dict[str, Any], ...] = ()
options: tuple[dict[str, Any], ...] = ()


class HelpRenderer(argparse.HelpFormatter):
Expand Down
33 changes: 25 additions & 8 deletions src/milo/templates/help.kida
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
{{ state.prog | bold }}{% if state.description %} {{ "-" | dim }} {{ state.description }}{% endif %}
{%- if state.commands %}

{{ "commands" | yellow | bold }}:
{%- for cmd in state.commands %}
{{ cmd.name | cyan }} {{ cmd.help }}
{%- endfor %}
{%- endif %}
{%- for group in state.groups %}

{% for group in state.groups %}
{{ group.title | yellow | bold }}:
{% for action in group.actions %} {% if action.option_strings %}{{ action.option_strings | join(", ") | green }}{% else %}{{ action.dest | cyan }}{% endif %}{% if action.metavar %} {{ action.metavar | dim }}{% endif %} {{ action.help }}{% if action.default is not none and action.default != "==SUPPRESS==" %} {{ ("(default: " ~ action.default ~ ")") | dim }}{% endif %}
{% endfor %}
{% endfor %}
{% if state.examples %}
{%- for action in group.actions %}
{% if action.option_strings %}{{ action.option_strings | join(", ") | green }}{% if action.metavar %} {{ action.metavar | dim }}{% endif %} {{ action.help }}{% if action.default is not none and action.default != "==SUPPRESS==" and action.default is not false and action.default != 0 and action.default != "" %} {{ ("(default: " ~ action.default ~ ")") | dim }}{% endif %}{% else %}{{ action.dest | cyan }}{% if action.metavar %} {{ action.metavar | dim }}{% endif %} {{ action.help }}{% if action.default is not none and action.default != "==SUPPRESS==" and action.default is not false and action.default != 0 and action.default != "" %} {{ ("(default: " ~ action.default ~ ")") | dim }}{% endif %}{% endif %}
{%- endfor %}
{%- endfor %}
{%- if state.options %}

{{ "options" | yellow | bold }}:
{%- for opt in state.options %}
{{ opt.flags | green }}{% if opt.metavar %} {{ opt.metavar | dim }}{% endif %} {{ opt.help }}{% if opt.default %} {{ ("(default: " ~ opt.default ~ ")") | dim }}{% endif %}
{%- endfor %}
{%- endif %}
{%- if state.examples %}

{{ "examples" | yellow | bold }}:
{% for ex in state.examples %} {{ ("$ " ~ ex.command) | green }}{% if ex.description %}
{%- for ex in state.examples %}
{{ ("$ " ~ ex.command) | green }}{% if ex.description %}
{{ ex.description | dim }}{% endif %}
{% endfor %}
{% endif %}
{%- endfor %}
{%- endif %}
Loading
Loading