Skip to content
Open
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
5 changes: 5 additions & 0 deletions libs/mngr/changelog/ev-improve-help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Improved help output for command groups.

The git-style help page (shown by `mngr <group> --help` and `mngr help <group>`) now includes a COMMANDS section listing a group's subcommands with their descriptions and aliases, so `mngr config --help`, `mngr snapshot --help`, and similar pages surface what you can do without hunting.

Running a command group with no subcommand (e.g. `mngr config`, `mngr snapshot`, `mngr git`, `mngr plugin`, or bare `mngr`) now consistently renders that same rich, pageable help page to stdout and exits with a usage-error status, instead of a plain, terminal-only usage line.
2 changes: 1 addition & 1 deletion libs/mngr/docs/commands/secondary/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
**Synopsis:**

```text
mngr [config|cfg] <subcommand> [OPTIONS]
mngr [config|cfg] [list|get|set|unset|edit|...] [ARGS]... [OPTIONS]
```

Manage mngr configuration.
Expand Down
2 changes: 1 addition & 1 deletion libs/mngr/docs/commands/secondary/plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
**Synopsis:**

```text
mngr [plugin|plug] <subcommand> [OPTIONS]
mngr [plugin|plug] [list|add|remove|enable|disable|...] [ARGS]... [OPTIONS]
```

Manage available and active plugins.
Expand Down
5 changes: 3 additions & 2 deletions libs/mngr/imbue/mngr/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from imbue.mngr.agents.agent_registry import list_registered_agent_types
from imbue.mngr.cli.common_opts import add_common_options
from imbue.mngr.cli.common_opts import setup_command_context
from imbue.mngr.cli.default_command_group import DefaultCommandGroup
from imbue.mngr.cli.help_formatter import CommandHelpMetadata
from imbue.mngr.cli.help_formatter import add_pager_help_option
from imbue.mngr.cli.output_helpers import AbortError
Expand Down Expand Up @@ -170,7 +171,7 @@ def _flatten_config(config: dict[str, Any], prefix: str = "") -> list[tuple[str,
return result


@click.group(name="config")
@click.group(name="config", cls=DefaultCommandGroup)
@click.option(
"--scope",
type=click.Choice(["user", "project", "local"], case_sensitive=False),
Expand Down Expand Up @@ -1251,7 +1252,7 @@ def _wizard_claude_config_isolation(
CommandHelpMetadata(
key="config",
one_line_description="Manage mngr configuration",
synopsis="mngr [config|cfg] <subcommand> [OPTIONS]",
synopsis="mngr [config|cfg] [list|get|set|unset|edit|...] [ARGS]... [OPTIONS]",
description="""View, edit, and modify mngr configuration settings at the user, project, or
local level. Much like a simpler version of `git config`, this command allows
you to manage configuration settings at different scopes.
Expand Down
31 changes: 29 additions & 2 deletions libs/mngr/imbue/mngr/cli/default_command_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import click

from imbue.mngr.cli.help_formatter import show_help_with_pager
from imbue.mngr.config.data_types import MngrConfig
from imbue.mngr.config.data_types import MngrContext
from imbue.mngr.config.pre_readers import read_default_command
from imbue.mngr.plugin_catalog import get_install_hint_for_cli_command

Expand Down Expand Up @@ -40,10 +43,34 @@ def make_context(
return super().make_context(info_name, args, parent=parent, **extra)

def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
if not args and self._default_command and not ctx.resilient_parsing:
args = [self._default_command]
if not args and not ctx.resilient_parsing:
if self._default_command:
args = [self._default_command]
else:
self._show_bare_help_and_exit(ctx)
return super().parse_args(ctx, args)

def _show_bare_help_and_exit(self, ctx: click.Context) -> None:
"""Render the git-style help page (to stdout, paged) and exit 2.

A bare group invocation with no default subcommand is a usage error --
a required subcommand is missing -- so we render the same rich,
pageable help page that ``--help`` shows and exit with code 2, rather
than falling through to click's stock ``no_args_is_help`` path (which
prints plain usage to stderr). Centralizing this here means every
derived group (the top-level ``mngr`` group, ``snapshot``, ``git``,
``config``, ``plugin``) gets identical bare-invocation behavior.
"""
# ``ctx.obj`` may be a MngrContext or a PluginManager depending on how
# far parsing has progressed; only the former carries pager config.
obj = ctx.obj
if isinstance(obj, MngrContext):
config: MngrConfig | None = obj.config
else:
config = None
show_help_with_pager(ctx, self, config)
ctx.exit(2)

def resolve_command(
self, ctx: click.Context, args: list[str]
) -> tuple[str | None, click.Command | None, list[str]]:
Expand Down
79 changes: 79 additions & 0 deletions libs/mngr/imbue/mngr/cli/help_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
from imbue.mngr.cli.common_opts import COMMON_OPTIONS_GROUP_NAME
from imbue.mngr.cli.common_opts import find_option_group
from imbue.mngr.config.data_types import MngrConfig
from imbue.mngr.utils.click_utils import detect_alias_to_canonical
from imbue.mngr.utils.click_utils import detect_aliases_by_command
from imbue.mngr.utils.interactive_subprocess import popen_interactive_subprocess


Expand Down Expand Up @@ -324,6 +326,12 @@ def _write_git_style_help(
wrapped = _wrap_text(paragraph.strip(), width - 7, " ", None)
output.write(f"{wrapped}\n\n")

# COMMANDS section (only for groups with visible subcommands).
# Placed before OPTIONS so the subcommand list -- the most important thing
# for discovering what a group can do -- is surfaced high, above the long
# shared-options list.
_write_commands_section(output, ctx, command, width)

# OPTIONS section
output.write(f"{_format_section_title('Options')}\n")
_write_options_section(output, ctx, command, width)
Expand Down Expand Up @@ -357,6 +365,77 @@ def _write_git_style_help(
output.write(f" $ {example}\n\n")


def _write_commands_section(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

why do all these _write_*_section commands write into a buffer instead of being pure functions that return a string and are assembled later

output: StringIO,
ctx: click.Context,
command: click.Command,
width: int,
) -> None:
"""Write the COMMANDS section listing a group's subcommands.

Does nothing unless ``command`` is a group with at least one visible
subcommand. Each subcommand is shown with its aliases inline and its
one-line description (from the git-style help metadata when registered,
otherwise click's short help), so the git-style page is as discoverable
as click's stock command listing.
"""
if not isinstance(command, click.Group):
return

alias_to_canonical = detect_alias_to_canonical(command)
aliases_by_command = detect_aliases_by_command(command)

# The metadata registry is keyed by canonical dot-path (e.g. "snapshot.create"),
# so build the parent's key once and prefix each subcommand name with it.
parent_key = _build_help_key(ctx)

rows: list[tuple[str, str]] = []
for subcommand_name in command.list_commands(ctx):
# Skip alias entries -- they are shown inline with their canonical command.
if subcommand_name in alias_to_canonical:
continue
subcommand = command.get_command(ctx, subcommand_name)
if subcommand is None or subcommand.hidden:
continue

child_key = f"{parent_key}.{subcommand_name}" if parent_key else subcommand_name
metadata = _help_metadata_registry.get(child_key)
if metadata is not None:
description = metadata.one_line_description
else:
description = subcommand.get_short_help_str(limit=width)

aliases = aliases_by_command.get(subcommand_name, [])
display_name = ", ".join([subcommand_name] + aliases) if aliases else subcommand_name
rows.append((display_name, description))

if not rows:
return

output.write(f"{_format_section_title('Commands')}\n")
# Align descriptions in a column just past the widest name, but cap the
# column so one long name doesn't push every description off the page.
name_column = min(max(len(name) for name, _ in rows), 24)
for name, description in rows:
prefix = f" {name}"
if not description:
# No description available -- show the bare name so the command is
# never dropped (``_wrap_text`` emits nothing for empty text, which
# would otherwise swallow the name it carries as the indent).
output.write(f"{prefix}\n")
continue
if len(name) <= name_column:
prefix = prefix.ljust(7 + name_column + 3)
wrapped = _wrap_text(description, width, prefix, " " * len(prefix))
output.write(f"{wrapped}\n")
else:
# Name is wider than the column -- put the description on its own line.
output.write(f"{prefix}\n")
wrapped = _wrap_text(description, width, " " * (7 + name_column + 3), None)
output.write(f"{wrapped}\n")
output.write("\n")


def _write_options_section(
output: StringIO,
ctx: click.Context,
Expand Down
94 changes: 94 additions & 0 deletions libs/mngr/imbue/mngr/cli/help_formatter_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,100 @@ def test_cmd(name: str | None, verbose: bool) -> None:
assert "mngr test --name foo" in help_text


def test_format_git_style_help_group_lists_commands() -> None:
"""A group's git-style help includes a COMMANDS section listing its subcommands."""

# Use subcommand names that do NOT collide with any command in the global
# help-metadata registry (e.g. "create"/"list"): a standalone context has no
# parent, so the COMMANDS lookup keys on the bare subcommand name, which would
# otherwise resolve to the real top-level command's registered description.
@click.group()
def grp() -> None:
pass

@grp.command(name="frobnicate", help="Frobnicate a thing")
def frobnicate_cmd() -> None:
pass

@grp.command(name="twiddle", help="Twiddle the knobs")
def twiddle_cmd() -> None:
pass

# Register 'tw' as an alias of the twiddle command.
grp.add_command(twiddle_cmd, name="tw")

metadata = CommandHelpMetadata(
key="grp",
one_line_description="A group of things",
synopsis="mngr grp COMMAND [ARGS]...",
description="Does group things.",
)

runner = CliRunner()
with runner.isolated_filesystem():
ctx = click.Context(grp)
help_text = format_git_style_help(ctx, grp, metadata)

assert "COMMANDS" in help_text
assert "Frobnicate a thing" in help_text
assert "Twiddle the knobs" in help_text
# The alias is shown inline with its canonical command, not as a separate row.
assert "twiddle, tw" in help_text
# COMMANDS is surfaced above the (long) shared OPTIONS list.
assert help_text.index("COMMANDS") < help_text.index("OPTIONS")


def test_format_git_style_help_group_lists_command_without_help() -> None:
"""A subcommand with no help/short-help still appears in COMMANDS (its name is not dropped)."""

@click.group()
def grp() -> None:
pass

# No ``help=``/``short_help=`` -> click's get_short_help_str returns "".
@grp.command(name="frobnicate")
def frobnicate_cmd() -> None:
pass

metadata = CommandHelpMetadata(
key="grp",
one_line_description="A group of things",
synopsis="mngr grp COMMAND [ARGS]...",
description="Does group things.",
)

runner = CliRunner()
with runner.isolated_filesystem():
ctx = click.Context(grp)
help_text = format_git_style_help(ctx, grp, metadata)

assert "COMMANDS" in help_text
assert "frobnicate" in help_text


def test_format_git_style_help_non_group_has_no_commands_section() -> None:
"""A leaf command (not a group) must not emit a COMMANDS section."""

@click.command()
@click.option("--name", help="The name to use")
def leaf_cmd(name: str | None) -> None:
pass

metadata = CommandHelpMetadata(
key="leaf",
one_line_description="A leaf command",
synopsis="mngr leaf [OPTIONS]",
description="Does a leaf thing.",
)

runner = CliRunner()
with runner.isolated_filesystem():
ctx = click.Context(leaf_cmd)
help_text = format_git_style_help(ctx, leaf_cmd, metadata)

assert "COMMANDS" not in help_text


def test_format_git_style_help_without_metadata() -> None:
"""Test that standard click help is used when no metadata is available."""

Expand Down
5 changes: 3 additions & 2 deletions libs/mngr/imbue/mngr/cli/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from imbue.mngr.cli.common_opts import add_common_options
from imbue.mngr.cli.common_opts import setup_command_context
from imbue.mngr.cli.config import get_config_path
from imbue.mngr.cli.default_command_group import DefaultCommandGroup
from imbue.mngr.cli.help_formatter import CommandHelpMetadata
from imbue.mngr.cli.help_formatter import add_pager_help_option
from imbue.mngr.cli.output_helpers import AbortError
Expand Down Expand Up @@ -335,7 +336,7 @@ def _emit_plugin_remove_result(
assert_never(unreachable)


@click.group(name="plugin")
@click.group(name="plugin", cls=DefaultCommandGroup)
@add_common_options
@click.pass_context
def plugin(ctx: click.Context, **kwargs: Any) -> None:
Expand Down Expand Up @@ -821,7 +822,7 @@ def _emit_plugin_toggle_result(
CommandHelpMetadata(
key="plugin",
one_line_description="Manage available and active plugins",
synopsis="mngr [plugin|plug] <subcommand> [OPTIONS]",
synopsis="mngr [plugin|plug] [list|add|remove|enable|disable|...] [ARGS]... [OPTIONS]",
description="""Install, remove, view, enable, and disable plugins registered with mngr.
Plugins provide agent types, provider backends, CLI commands, and lifecycle hooks.""",
aliases=("plug",),
Expand Down
19 changes: 16 additions & 3 deletions libs/mngr/imbue/mngr/cli/snapshot_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,22 @@ def test_snapshot_bare_invocation_shows_help(
cli_runner: CliRunner,
plugin_manager: pluggy.PluginManager,
) -> None:
"""Running `mngr snapshot` with no args should show help (no default command)."""
result = cli_runner.invoke(snapshot, [], obj=plugin_manager)
assert "Commands:" in result.output or "Usage:" in result.output
"""Running `mngr snapshot` with no default command shows the git-style help page.

A missing required subcommand is a usage error, so the exit code is 2 (not
0), and the rich man-page-style help -- including the COMMANDS section that
lists the subcommands -- is rendered rather than plain click usage. Must
invoke through the root cli group so that _build_help_key produces the
qualified key ("snapshot") for metadata resolution.
"""
result = cli_runner.invoke(cli, ["snapshot"])
assert result.exit_code == 2
assert "NAME" in result.output
assert "SYNOPSIS" in result.output
# The COMMANDS section surfaces the subcommands on the bare page.
assert "COMMANDS" in result.output
assert "create" in result.output
assert "destroy" in result.output


def test_snapshot_unrecognized_subcommand_errors(
Expand Down
6 changes: 3 additions & 3 deletions libs/mngr/imbue/mngr/cli/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,9 @@ def test_plugin_without_subcommand_shows_help(
) -> None:
"""Invoking plugin with no subcommand is a usage error that shows help.

``plugin`` requires a subcommand (it is not ``invoke_without_command``), so
click renders the usage/help listing and exits with the standard usage-error
code, exactly like every other required-subcommand group.
``plugin`` requires a subcommand (it is a ``DefaultCommandGroup`` with no
default), so the group renders its help listing and exits with the
usage-error code (2), exactly like every other required-subcommand group.
"""
result = cli_runner.invoke(
plugin,
Expand Down
Loading