From 257edb726f25ea91775cd49641e50d83935a91cb Mon Sep 17 00:00:00 2001 From: Evan Ryan Gunter Date: Sat, 11 Jul 2026 21:31:00 -0700 Subject: [PATCH 1/9] Improve group help: COMMANDS section + unified git-style bare-invocation page Fixes issues #10 and #15 from the local tracker. #10: the git-style help page (mngr --help, mngr help ) now renders a COMMANDS section listing a group's subcommands with descriptions and inline aliases, so config/snapshot/git/plugin help surface what you can do. Placed before OPTIONS for prominence. #15: centralize bare group invocation in DefaultCommandGroup -- with no subcommand it renders the same rich, pageable git-style page to stdout and exits 2, instead of click's plain stderr usage line. config and plugin are migrated onto DefaultCommandGroup so they behave identically to snapshot/git and the top-level mngr group. config/plugin synopsis switched to the click-standard "COMMAND [ARGS]...". Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/mngr/changelog/ev-improve-help.md | 5 ++ libs/mngr/docs/commands/secondary/config.md | 2 +- libs/mngr/docs/commands/secondary/plugin.md | 2 +- libs/mngr/imbue/mngr/cli/config.py | 11 +-- .../imbue/mngr/cli/default_command_group.py | 29 +++++++- libs/mngr/imbue/mngr/cli/help_formatter.py | 73 +++++++++++++++++++ .../imbue/mngr/cli/help_formatter_test.py | 66 +++++++++++++++++ libs/mngr/imbue/mngr/cli/plugin.py | 11 +-- libs/mngr/imbue/mngr/cli/snapshot_test.py | 19 ++++- libs/mngr/imbue/mngr/cli/test_plugin.py | 6 +- .../imbue/mngr/default_command_group_test.py | 14 +++- 11 files changed, 215 insertions(+), 23 deletions(-) create mode 100644 libs/mngr/changelog/ev-improve-help.md 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..95810e73a3 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] COMMAND [ARGS]... ``` Manage mngr configuration. diff --git a/libs/mngr/docs/commands/secondary/plugin.md b/libs/mngr/docs/commands/secondary/plugin.md index 511f5fa276..a85b7cecf7 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] COMMAND [ARGS]... ``` 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..8253158bbf 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), @@ -179,9 +180,9 @@ def _flatten_config(config: dict[str, Any], prefix: str = "") -> list[tuple[str, @add_common_options @click.pass_context def config(ctx: click.Context, **kwargs: Any) -> None: - # A subcommand is required; running this group bare prints its help/usage - # listing and exits with a usage error (like snapshot/git), so this group - # callback has nothing to do itself. + # A subcommand is required; DefaultCommandGroup renders the git-style help + # page and exits with a usage error when the group is invoked bare (like + # snapshot/git), so this group callback has nothing to do itself. pass @@ -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] COMMAND [ARGS]...", 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..01fec13ea2 100644 --- a/libs/mngr/imbue/mngr/cli/default_command_group.py +++ b/libs/mngr/imbue/mngr/cli/default_command_group.py @@ -2,6 +2,8 @@ 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.pre_readers import read_default_command from imbue.mngr.plugin_catalog import get_install_hint_for_cli_command @@ -40,10 +42,33 @@ 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. + config: MngrConfig | None = None + obj = ctx.obj + if obj is not None and hasattr(obj, "config"): + config = obj.config + 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..0321e30943 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,71 @@ 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 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..7188d7d441 100644 --- a/libs/mngr/imbue/mngr/cli/help_formatter_test.py +++ b/libs/mngr/imbue/mngr/cli/help_formatter_test.py @@ -124,6 +124,72 @@ 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_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..a23f6d1d68 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,13 +336,13 @@ 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: - # A subcommand is required; running this group bare prints its help/usage - # listing and exits with a usage error (like snapshot/git), so this group - # callback has nothing to do itself. + # A subcommand is required; DefaultCommandGroup renders the git-style help + # page and exits with a usage error when the group is invoked bare (like + # snapshot/git), so this group callback has nothing to do itself. pass @@ -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] COMMAND [ARGS]...", 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 From 5234958ca955fadd359412f85c6af398aaa14941 Mon Sep 17 00:00:00 2001 From: Evan Ryan Gunter Date: Sat, 11 Jul 2026 21:32:45 -0700 Subject: [PATCH 2/9] Show common subcommands in config/plugin synopsis instead of bare COMMAND With many subcommands, enumerate the most common few plus "..." (config: list|get|set, plugin: list|add|remove) rather than a bare COMMAND -- more concrete and consistent with snapshot/git, which enumerate. The COMMANDS section lists the full set below. Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/mngr/docs/commands/secondary/config.md | 2 +- libs/mngr/docs/commands/secondary/plugin.md | 2 +- libs/mngr/imbue/mngr/cli/config.py | 2 +- libs/mngr/imbue/mngr/cli/plugin.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/mngr/docs/commands/secondary/config.md b/libs/mngr/docs/commands/secondary/config.md index 95810e73a3..9634575ec8 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] COMMAND [ARGS]... +mngr [config|cfg] [list|get|set|...] [ARGS]... ``` Manage mngr configuration. diff --git a/libs/mngr/docs/commands/secondary/plugin.md b/libs/mngr/docs/commands/secondary/plugin.md index a85b7cecf7..ec0d41c80e 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] COMMAND [ARGS]... +mngr [plugin|plug] [list|add|remove|...] [ARGS]... ``` Manage available and active plugins. diff --git a/libs/mngr/imbue/mngr/cli/config.py b/libs/mngr/imbue/mngr/cli/config.py index 8253158bbf..d454b18eda 100644 --- a/libs/mngr/imbue/mngr/cli/config.py +++ b/libs/mngr/imbue/mngr/cli/config.py @@ -1252,7 +1252,7 @@ def _wizard_claude_config_isolation( CommandHelpMetadata( key="config", one_line_description="Manage mngr configuration", - synopsis="mngr [config|cfg] COMMAND [ARGS]...", + synopsis="mngr [config|cfg] [list|get|set|...] [ARGS]...", 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/plugin.py b/libs/mngr/imbue/mngr/cli/plugin.py index a23f6d1d68..f53cc70064 100644 --- a/libs/mngr/imbue/mngr/cli/plugin.py +++ b/libs/mngr/imbue/mngr/cli/plugin.py @@ -822,7 +822,7 @@ def _emit_plugin_toggle_result( CommandHelpMetadata( key="plugin", one_line_description="Manage available and active plugins", - synopsis="mngr [plugin|plug] COMMAND [ARGS]...", + synopsis="mngr [plugin|plug] [list|add|remove|...] [ARGS]...", description="""Install, remove, view, enable, and disable plugins registered with mngr. Plugins provide agent types, provider backends, CLI commands, and lifecycle hooks.""", aliases=("plug",), From 584df3d6e5ab12aff45f1430fe349d6d4b9170da Mon Sep 17 00:00:00 2001 From: Evan Ryan Gunter Date: Sat, 11 Jul 2026 21:37:24 -0700 Subject: [PATCH 3/9] Refine config/plugin synopsis subcommand enumeration config: [get|set|unset|list|...] (CRUD basics, mutating verbs first). plugin: [add|remove|enable|disable|...] (lifecycle verbs; ... covers list and the user-facing install-wizard). Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/mngr/docs/commands/secondary/config.md | 2 +- libs/mngr/docs/commands/secondary/plugin.md | 2 +- libs/mngr/imbue/mngr/cli/config.py | 2 +- libs/mngr/imbue/mngr/cli/plugin.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/mngr/docs/commands/secondary/config.md b/libs/mngr/docs/commands/secondary/config.md index 9634575ec8..98d22a9be7 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] [list|get|set|...] [ARGS]... +mngr [config|cfg] [get|set|unset|list|...] [ARGS]... ``` Manage mngr configuration. diff --git a/libs/mngr/docs/commands/secondary/plugin.md b/libs/mngr/docs/commands/secondary/plugin.md index ec0d41c80e..491583f2c1 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] [list|add|remove|...] [ARGS]... +mngr [plugin|plug] [add|remove|enable|disable|...] [ARGS]... ``` Manage available and active plugins. diff --git a/libs/mngr/imbue/mngr/cli/config.py b/libs/mngr/imbue/mngr/cli/config.py index d454b18eda..3595a71696 100644 --- a/libs/mngr/imbue/mngr/cli/config.py +++ b/libs/mngr/imbue/mngr/cli/config.py @@ -1252,7 +1252,7 @@ def _wizard_claude_config_isolation( CommandHelpMetadata( key="config", one_line_description="Manage mngr configuration", - synopsis="mngr [config|cfg] [list|get|set|...] [ARGS]...", + synopsis="mngr [config|cfg] [get|set|unset|list|...] [ARGS]...", 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/plugin.py b/libs/mngr/imbue/mngr/cli/plugin.py index f53cc70064..f17c89b64b 100644 --- a/libs/mngr/imbue/mngr/cli/plugin.py +++ b/libs/mngr/imbue/mngr/cli/plugin.py @@ -822,7 +822,7 @@ def _emit_plugin_toggle_result( CommandHelpMetadata( key="plugin", one_line_description="Manage available and active plugins", - synopsis="mngr [plugin|plug] [list|add|remove|...] [ARGS]...", + synopsis="mngr [plugin|plug] [add|remove|enable|disable|...] [ARGS]...", description="""Install, remove, view, enable, and disable plugins registered with mngr. Plugins provide agent types, provider backends, CLI commands, and lifecycle hooks.""", aliases=("plug",), From 9600f11eb540c637062e00ae22f33b298d2f4c42 Mon Sep 17 00:00:00 2001 From: Evan Ryan Gunter Date: Sat, 11 Jul 2026 21:41:02 -0700 Subject: [PATCH 4/9] Put list first and include it in config/plugin synopsis config: [list|get|set|unset|...]; plugin: [list|add|remove|enable|disable|...]. list leads (the discovery command) with the full width still under 80 cols. Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/mngr/docs/commands/secondary/config.md | 2 +- libs/mngr/docs/commands/secondary/plugin.md | 2 +- libs/mngr/imbue/mngr/cli/config.py | 2 +- libs/mngr/imbue/mngr/cli/plugin.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/mngr/docs/commands/secondary/config.md b/libs/mngr/docs/commands/secondary/config.md index 98d22a9be7..b9d5a11e9d 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] [get|set|unset|list|...] [ARGS]... +mngr [config|cfg] [list|get|set|unset|...] [ARGS]... ``` Manage mngr configuration. diff --git a/libs/mngr/docs/commands/secondary/plugin.md b/libs/mngr/docs/commands/secondary/plugin.md index 491583f2c1..31c6d2cd01 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] [add|remove|enable|disable|...] [ARGS]... +mngr [plugin|plug] [list|add|remove|enable|disable|...] [ARGS]... ``` Manage available and active plugins. diff --git a/libs/mngr/imbue/mngr/cli/config.py b/libs/mngr/imbue/mngr/cli/config.py index 3595a71696..97f421ee27 100644 --- a/libs/mngr/imbue/mngr/cli/config.py +++ b/libs/mngr/imbue/mngr/cli/config.py @@ -1252,7 +1252,7 @@ def _wizard_claude_config_isolation( CommandHelpMetadata( key="config", one_line_description="Manage mngr configuration", - synopsis="mngr [config|cfg] [get|set|unset|list|...] [ARGS]...", + synopsis="mngr [config|cfg] [list|get|set|unset|...] [ARGS]...", 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/plugin.py b/libs/mngr/imbue/mngr/cli/plugin.py index f17c89b64b..cfb565da97 100644 --- a/libs/mngr/imbue/mngr/cli/plugin.py +++ b/libs/mngr/imbue/mngr/cli/plugin.py @@ -822,7 +822,7 @@ def _emit_plugin_toggle_result( CommandHelpMetadata( key="plugin", one_line_description="Manage available and active plugins", - synopsis="mngr [plugin|plug] [add|remove|enable|disable|...] [ARGS]...", + synopsis="mngr [plugin|plug] [list|add|remove|enable|disable|...] [ARGS]...", description="""Install, remove, view, enable, and disable plugins registered with mngr. Plugins provide agent types, provider backends, CLI commands, and lifecycle hooks.""", aliases=("plug",), From eef5e7c9f6354160b5a3a86ac4c1f51baa1fd110 Mon Sep 17 00:00:00 2001 From: Evan Ryan Gunter Date: Sat, 11 Jul 2026 21:45:01 -0700 Subject: [PATCH 5/9] Add edit to the config synopsis subcommand list config: [list|get|set|unset|edit|...] -- surfaces the whole-file editor escape hatch alongside the CRUD basics; still under 80 cols. Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/mngr/docs/commands/secondary/config.md | 2 +- libs/mngr/imbue/mngr/cli/config.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/mngr/docs/commands/secondary/config.md b/libs/mngr/docs/commands/secondary/config.md index b9d5a11e9d..f4432b595b 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] [list|get|set|unset|...] [ARGS]... +mngr [config|cfg] [list|get|set|unset|edit|...] [ARGS]... ``` Manage mngr configuration. diff --git a/libs/mngr/imbue/mngr/cli/config.py b/libs/mngr/imbue/mngr/cli/config.py index 97f421ee27..878415e2f5 100644 --- a/libs/mngr/imbue/mngr/cli/config.py +++ b/libs/mngr/imbue/mngr/cli/config.py @@ -1252,7 +1252,7 @@ def _wizard_claude_config_isolation( CommandHelpMetadata( key="config", one_line_description="Manage mngr configuration", - synopsis="mngr [config|cfg] [list|get|set|unset|...] [ARGS]...", + synopsis="mngr [config|cfg] [list|get|set|unset|edit|...] [ARGS]...", 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. From 73bac48ef567700bacd562830d5a3c7a8403a24f Mon Sep 17 00:00:00 2001 From: Evan Ryan Gunter Date: Sat, 11 Jul 2026 21:55:58 -0700 Subject: [PATCH 6/9] Restore [OPTIONS] in the config/plugin synopsis Dropped inadvertently when switching from " [OPTIONS]" to the enumerated form. Placed after [ARGS]..., matching snapshot's synopsis; the group and its subcommands do take options (--scope, etc.). Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/mngr/docs/commands/secondary/config.md | 2 +- libs/mngr/docs/commands/secondary/plugin.md | 2 +- libs/mngr/imbue/mngr/cli/config.py | 2 +- libs/mngr/imbue/mngr/cli/plugin.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/mngr/docs/commands/secondary/config.md b/libs/mngr/docs/commands/secondary/config.md index f4432b595b..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] [list|get|set|unset|edit|...] [ARGS]... +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 31c6d2cd01..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] [list|add|remove|enable|disable|...] [ARGS]... +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 878415e2f5..2014b14775 100644 --- a/libs/mngr/imbue/mngr/cli/config.py +++ b/libs/mngr/imbue/mngr/cli/config.py @@ -1252,7 +1252,7 @@ def _wizard_claude_config_isolation( CommandHelpMetadata( key="config", one_line_description="Manage mngr configuration", - synopsis="mngr [config|cfg] [list|get|set|unset|edit|...] [ARGS]...", + 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/plugin.py b/libs/mngr/imbue/mngr/cli/plugin.py index cfb565da97..39fd4c681d 100644 --- a/libs/mngr/imbue/mngr/cli/plugin.py +++ b/libs/mngr/imbue/mngr/cli/plugin.py @@ -822,7 +822,7 @@ def _emit_plugin_toggle_result( CommandHelpMetadata( key="plugin", one_line_description="Manage available and active plugins", - synopsis="mngr [plugin|plug] [list|add|remove|enable|disable|...] [ARGS]...", + 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",), From c022380749bfed1500cdef32d4e07e962d6aea1c Mon Sep 17 00:00:00 2001 From: Evan Ryan Gunter Date: Sat, 11 Jul 2026 21:56:54 -0700 Subject: [PATCH 7/9] Revert needless comment rewording on config/plugin group callbacks Keep the original phrasing from #2432; it's still accurate and doesn't need the added mechanism detail. Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/mngr/imbue/mngr/cli/config.py | 6 +++--- libs/mngr/imbue/mngr/cli/plugin.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/libs/mngr/imbue/mngr/cli/config.py b/libs/mngr/imbue/mngr/cli/config.py index 2014b14775..4a957ea42b 100644 --- a/libs/mngr/imbue/mngr/cli/config.py +++ b/libs/mngr/imbue/mngr/cli/config.py @@ -180,9 +180,9 @@ def _flatten_config(config: dict[str, Any], prefix: str = "") -> list[tuple[str, @add_common_options @click.pass_context def config(ctx: click.Context, **kwargs: Any) -> None: - # A subcommand is required; DefaultCommandGroup renders the git-style help - # page and exits with a usage error when the group is invoked bare (like - # snapshot/git), so this group callback has nothing to do itself. + # A subcommand is required; running this group bare prints its help/usage + # listing and exits with a usage error (like snapshot/git), so this group + # callback has nothing to do itself. pass diff --git a/libs/mngr/imbue/mngr/cli/plugin.py b/libs/mngr/imbue/mngr/cli/plugin.py index 39fd4c681d..894a9862b5 100644 --- a/libs/mngr/imbue/mngr/cli/plugin.py +++ b/libs/mngr/imbue/mngr/cli/plugin.py @@ -340,9 +340,9 @@ def _emit_plugin_remove_result( @add_common_options @click.pass_context def plugin(ctx: click.Context, **kwargs: Any) -> None: - # A subcommand is required; DefaultCommandGroup renders the git-style help - # page and exits with a usage error when the group is invoked bare (like - # snapshot/git), so this group callback has nothing to do itself. + # A subcommand is required; running this group bare prints its help/usage + # listing and exits with a usage error (like snapshot/git), so this group + # callback has nothing to do itself. pass From b0f8871cb6d7ce98d646a3646cf4b723d6d1b2e7 Mon Sep 17 00:00:00 2001 From: Evan Ryan Gunter Date: Sat, 11 Jul 2026 22:01:09 -0700 Subject: [PATCH 8/9] Simplify pager-config extraction in DefaultCommandGroup bare-help path Use an isinstance(MngrContext) check instead of hasattr, and assign config in an if/else rather than initializing-then-reassigning. Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/mngr/imbue/mngr/cli/default_command_group.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/libs/mngr/imbue/mngr/cli/default_command_group.py b/libs/mngr/imbue/mngr/cli/default_command_group.py index 01fec13ea2..7331f62447 100644 --- a/libs/mngr/imbue/mngr/cli/default_command_group.py +++ b/libs/mngr/imbue/mngr/cli/default_command_group.py @@ -4,6 +4,7 @@ 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 @@ -62,10 +63,11 @@ def _show_bare_help_and_exit(self, ctx: click.Context) -> None: """ # ``ctx.obj`` may be a MngrContext or a PluginManager depending on how # far parsing has progressed; only the former carries pager config. - config: MngrConfig | None = None obj = ctx.obj - if obj is not None and hasattr(obj, "config"): - config = obj.config + if isinstance(obj, MngrContext): + config: MngrConfig | None = obj.config + else: + config = None show_help_with_pager(ctx, self, config) ctx.exit(2) From 2575a6b850241deb758e92dedd6cd3c90f1509c2 Mon Sep 17 00:00:00 2001 From: Evan Ryan Gunter Date: Sat, 11 Jul 2026 22:21:45 -0700 Subject: [PATCH 9/9] Show help-less subcommands in the COMMANDS listing Problem: In help_formatter._write_commands_section, a subcommand's name was passed as the initial_indent to _wrap_text and only emitted as part of the wrapped description. _wrap_text returns '' for empty input, so a subcommand with no metadata and no click short-help (get_short_help_str == '') had its name silently dropped, rendering a blank row (and a spurious extra blank line in the wide-name branch). Fix: Write the bare name line and continue when the description is empty, so the command is never dropped. Add a regression unit test covering a group whose subcommand has no help text. Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/mngr/imbue/mngr/cli/help_formatter.py | 6 ++++ .../imbue/mngr/cli/help_formatter_test.py | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/libs/mngr/imbue/mngr/cli/help_formatter.py b/libs/mngr/imbue/mngr/cli/help_formatter.py index 0321e30943..07297b8eec 100644 --- a/libs/mngr/imbue/mngr/cli/help_formatter.py +++ b/libs/mngr/imbue/mngr/cli/help_formatter.py @@ -418,6 +418,12 @@ def _write_commands_section( 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)) diff --git a/libs/mngr/imbue/mngr/cli/help_formatter_test.py b/libs/mngr/imbue/mngr/cli/help_formatter_test.py index 7188d7d441..1481f839e8 100644 --- a/libs/mngr/imbue/mngr/cli/help_formatter_test.py +++ b/libs/mngr/imbue/mngr/cli/help_formatter_test.py @@ -167,6 +167,34 @@ def twiddle_cmd() -> None: 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."""