diff --git a/libs/mngr/changelog/ev-improve-help.md b/libs/mngr/changelog/ev-improve-help.md new file mode 100644 index 0000000000..91cc52e903 --- /dev/null +++ b/libs/mngr/changelog/ev-improve-help.md @@ -0,0 +1,5 @@ +Improved help output for command groups. + +The git-style help page (shown by `mngr --help` and `mngr help `) 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. diff --git a/libs/mngr/docs/commands/secondary/config.md b/libs/mngr/docs/commands/secondary/config.md index 49c272e178..49f84f80d7 100644 --- a/libs/mngr/docs/commands/secondary/config.md +++ b/libs/mngr/docs/commands/secondary/config.md @@ -6,7 +6,7 @@ **Synopsis:** ```text -mngr [config|cfg] [OPTIONS] +mngr [config|cfg] [list|get|set|unset|edit|...] [ARGS]... [OPTIONS] ``` Manage mngr configuration. diff --git a/libs/mngr/docs/commands/secondary/plugin.md b/libs/mngr/docs/commands/secondary/plugin.md index 511f5fa276..1b697c8028 100644 --- a/libs/mngr/docs/commands/secondary/plugin.md +++ b/libs/mngr/docs/commands/secondary/plugin.md @@ -6,7 +6,7 @@ **Synopsis:** ```text -mngr [plugin|plug] [OPTIONS] +mngr [plugin|plug] [list|add|remove|enable|disable|...] [ARGS]... [OPTIONS] ``` Manage available and active plugins. diff --git a/libs/mngr/imbue/mngr/cli/config.py b/libs/mngr/imbue/mngr/cli/config.py index 394957aa3d..4a957ea42b 100644 --- a/libs/mngr/imbue/mngr/cli/config.py +++ b/libs/mngr/imbue/mngr/cli/config.py @@ -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 @@ -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), @@ -1251,7 +1252,7 @@ def _wizard_claude_config_isolation( CommandHelpMetadata( key="config", one_line_description="Manage mngr configuration", - synopsis="mngr [config|cfg] [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. diff --git a/libs/mngr/imbue/mngr/cli/default_command_group.py b/libs/mngr/imbue/mngr/cli/default_command_group.py index 1b779fb915..7331f62447 100644 --- a/libs/mngr/imbue/mngr/cli/default_command_group.py +++ b/libs/mngr/imbue/mngr/cli/default_command_group.py @@ -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 @@ -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]]: diff --git a/libs/mngr/imbue/mngr/cli/help_formatter.py b/libs/mngr/imbue/mngr/cli/help_formatter.py index 6b358f2863..07297b8eec 100644 --- a/libs/mngr/imbue/mngr/cli/help_formatter.py +++ b/libs/mngr/imbue/mngr/cli/help_formatter.py @@ -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 @@ -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) @@ -357,6 +365,77 @@ def _write_git_style_help( output.write(f" $ {example}\n\n") +def _write_commands_section( + 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, diff --git a/libs/mngr/imbue/mngr/cli/help_formatter_test.py b/libs/mngr/imbue/mngr/cli/help_formatter_test.py index 41718f8a47..1481f839e8 100644 --- a/libs/mngr/imbue/mngr/cli/help_formatter_test.py +++ b/libs/mngr/imbue/mngr/cli/help_formatter_test.py @@ -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.""" diff --git a/libs/mngr/imbue/mngr/cli/plugin.py b/libs/mngr/imbue/mngr/cli/plugin.py index 1137eb68ae..894a9862b5 100644 --- a/libs/mngr/imbue/mngr/cli/plugin.py +++ b/libs/mngr/imbue/mngr/cli/plugin.py @@ -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 @@ -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: @@ -821,7 +822,7 @@ def _emit_plugin_toggle_result( CommandHelpMetadata( key="plugin", one_line_description="Manage available and active plugins", - synopsis="mngr [plugin|plug] [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",), diff --git a/libs/mngr/imbue/mngr/cli/snapshot_test.py b/libs/mngr/imbue/mngr/cli/snapshot_test.py index 66c61d4f36..959c559e0b 100644 --- a/libs/mngr/imbue/mngr/cli/snapshot_test.py +++ b/libs/mngr/imbue/mngr/cli/snapshot_test.py @@ -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( diff --git a/libs/mngr/imbue/mngr/cli/test_plugin.py b/libs/mngr/imbue/mngr/cli/test_plugin.py index 02c30cf5e7..92c6b0f24b 100644 --- a/libs/mngr/imbue/mngr/cli/test_plugin.py +++ b/libs/mngr/imbue/mngr/cli/test_plugin.py @@ -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, diff --git a/libs/mngr/imbue/mngr/default_command_group_test.py b/libs/mngr/imbue/mngr/default_command_group_test.py index aa7d5ebd3c..44064b9a25 100644 --- a/libs/mngr/imbue/mngr/default_command_group_test.py +++ b/libs/mngr/imbue/mngr/default_command_group_test.py @@ -47,11 +47,16 @@ def list_cmd() -> None: def test_bare_invocation_shows_help_by_default() -> None: - """Running the group with no args and no default command should show help.""" + """Running the group with no args and no default command shows help and exits 2. + + A missing required subcommand is a usage error, so the exit code is 2 and + the group callback never runs (nothing is recorded). + """ record: dict[str, str | None] = {} group = _make_test_group(record) runner = CliRunner() result = runner.invoke(group, []) + assert result.exit_code == 2 assert "Commands:" in result.output or "Usage:" in result.output assert "command" not in record @@ -138,8 +143,9 @@ def test_mngr_bare_invocation_shows_help( plugin_manager: pluggy.PluginManager, temp_git_repo_cwd: Path, ) -> None: - """Running `mngr` with no args should show help (no default command).""" + """Running `mngr` with no args should show help and exit 2 (no default command).""" result = cli_runner.invoke(cli, [], obj=plugin_manager) + assert result.exit_code == 2 assert "Commands:" in result.output or "Usage:" in result.output @@ -158,8 +164,9 @@ def test_mngr_snapshot_bare_shows_help( cli_runner: CliRunner, plugin_manager: pluggy.PluginManager, ) -> None: - """Running `mngr snapshot` with no args should show help (no default command).""" + """Running `mngr snapshot` with no args should show help and exit 2 (no default command).""" result = cli_runner.invoke(snapshot, [], obj=plugin_manager) + assert result.exit_code == 2 assert "Commands:" in result.output or "Usage:" in result.output @@ -242,6 +249,7 @@ def test_config_key_disabled_shows_help( group = _make_config_key_group(record, config_key="testgrp") runner = CliRunner() result = runner.invoke(group, []) + assert result.exit_code == 2 assert "Commands:" in result.output or "Usage:" in result.output assert "command" not in record