From 6f8991a4dcb5d80329261d4ae09e507cbc30614d Mon Sep 17 00:00:00 2001 From: Jan Scheffler Date: Tue, 24 Feb 2026 09:13:35 +0100 Subject: [PATCH 1/2] feat: add full command reference to root --help Show all commands with their args in a flat "All Commands" section at the bottom of `--help`, so agents and humans can discover everything in a single call without drilling into sub-commands. The reference is generated dynamically by walking registered sub-apps and inspecting function signatures. Long signatures are trimmed with `...` and descriptions are truncated to fit 78 columns. Co-Authored-By: Claude Opus 4.6 --- src/qodev_gitlab_cli/app.py | 133 +++++++++++++++++++++++++++++++++++- 1 file changed, 132 insertions(+), 1 deletion(-) diff --git a/src/qodev_gitlab_cli/app.py b/src/qodev_gitlab_cli/app.py index 116e654..058e75f 100644 --- a/src/qodev_gitlab_cli/app.py +++ b/src/qodev_gitlab_cli/app.py @@ -2,8 +2,10 @@ from __future__ import annotations +import inspect import sys -from typing import Annotated +import types +from typing import Annotated, Union, get_args, get_origin, get_type_hints from cyclopts import App, Group, Parameter from qodev_gitlab_api import APIError, AuthenticationError, ConfigurationError, NotFoundError @@ -13,6 +15,7 @@ app = App( name="qodev-gitlab", help="Agent-friendly CLI for the GitLab API.", + help_format="rich", version_flags=[], ) @@ -37,6 +40,134 @@ app.command(releases_app) app.command(variables_app) +# Prevent the command reference epilogue from showing on sub-command help pages. +for _sub in (projects_app, mrs_app, pipelines_app, jobs_app, issues_app, releases_app, variables_app): + _sub.help_epilogue = "" + + +# --------------------------------------------------------------------------- +# Dynamic command reference for root --help +# --------------------------------------------------------------------------- +def _is_bool_type(tp: type | None) -> bool: + if tp is bool: + return True + if tp is None: + return False + origin = get_origin(tp) + if origin is Union or isinstance(tp, types.UnionType): + return bool in get_args(tp) + return False + + +def _format_signature(func: object, prefix_len: int = 0, col_width: int = 50) -> str: + sig = inspect.signature(func) # type: ignore[arg-type] + try: + hints = get_type_hints(func, include_extras=True) + except Exception: + hints = {} + + required: list[str] = [] + optional: list[str] = [] + for pname, param in sig.parameters.items(): + hint = hints.get(pname) + + cli_param = None + base_type = hint + if hint is not None and get_origin(hint) is Annotated: + args = get_args(hint) + base_type = args[0] + for arg in args[1:]: + if isinstance(arg, Parameter): + cli_param = arg + break + + is_bool = _is_bool_type(base_type) + has_default = param.default is not inspect.Parameter.empty + + if param.kind == param.KEYWORD_ONLY: + if cli_param and cli_param.name: + names = cli_param.name if isinstance(cli_param.name, (list, tuple)) else [cli_param.name] + cli_name = names[0] + else: + cli_name = f"--{pname.replace('_', '-')}" + + if has_default: + optional.append(f"\\[{cli_name}]") + elif is_bool: + required.append(cli_name) + else: + required.append(f"{cli_name} {pname.upper()}") + elif param.kind in (param.POSITIONAL_ONLY, param.POSITIONAL_OR_KEYWORD): + label = pname.upper() + if has_default: + optional.append(f"\\[{label}]") + else: + required.append(label) + + # Progressively drop optional args from the end to fit column width + max_sig = col_width - prefix_len - 2 # 2 spaces gap before description + parts = required + optional + result = " ".join(parts) + dropped = 0 + while len(result) > max_sig and optional: + optional.pop() + dropped += 1 + parts = required + optional + (["..."] if dropped else []) + result = " ".join(parts) + + return result + + +def _display_len(s: str) -> int: + """Return the rendered width (Rich \\[ escapes become [).""" + return len(s.replace("\\[", "[")) + + +def _build_command_reference() -> str: + sub_apps = [ + projects_app, mrs_app, pipelines_app, jobs_app, + issues_app, releases_app, variables_app, + ] + + col_width = 46 + entries: list[tuple[str, str]] = [] + for sub in sub_apps: + sub_name = sub.name[0] + for cmd_name, cmd_app in sub._commands.items(): + if cmd_name.startswith("-"): + continue + func = cmd_app.default_command + if func is None: + continue + prefix = f" {sub_name} {cmd_name} " + sig_str = _format_signature(func, prefix_len=len(prefix), col_width=col_width) + doc = (func.__doc__ or "").strip().split("\n")[0] + left = f" {sub_name} {cmd_name}" + if sig_str: + left += f" {sig_str}" + entries.append((left, doc)) + entries.append(("", "")) + + if entries and entries[-1] == ("", ""): + entries.pop() + + max_line = 78 + lines = ["All Commands:\n"] + for left, doc in entries: + if not left: + lines.append("") + else: + display_w = _display_len(left) + pad = max(2, col_width - display_w) + avail = max_line - display_w - pad + if len(doc) > avail > 10: + doc = doc[: avail - 1] + "\u2026" + lines.append(f"{left}{' ' * pad}{doc}") + return "\n".join(lines) + + +app.help_epilogue = _build_command_reference() + # --------------------------------------------------------------------------- # Exit codes # --------------------------------------------------------------------------- From 865f969a648ba7c324e52c413da289ccc2fe8996 Mon Sep 17 00:00:00 2001 From: Jan Scheffler Date: Tue, 24 Feb 2026 09:25:17 +0100 Subject: [PATCH 2/2] refactor: extract help reference builder to dedicated module Address review feedback: - Move command reference logic to help_reference.py - Use public resolved_commands() API instead of private _commands - Narrow exception catch to (TypeError, NameError) - Fix type annotation on _is_bool_type (object, not type | None) - Remove dead default for col_width param - Guard against empty name tuple - Remove redundant dropped truthy check - Use consistent ellipsis character (unicode) - Name magic constants Co-Authored-By: Claude Opus 4.6 --- src/qodev_gitlab_cli/app.py | 142 ++----------------------- src/qodev_gitlab_cli/help_reference.py | 123 +++++++++++++++++++++ 2 files changed, 130 insertions(+), 135 deletions(-) create mode 100644 src/qodev_gitlab_cli/help_reference.py diff --git a/src/qodev_gitlab_cli/app.py b/src/qodev_gitlab_cli/app.py index 058e75f..09d0d11 100644 --- a/src/qodev_gitlab_cli/app.py +++ b/src/qodev_gitlab_cli/app.py @@ -2,10 +2,8 @@ from __future__ import annotations -import inspect import sys -import types -from typing import Annotated, Union, get_args, get_origin, get_type_hints +from typing import Annotated from cyclopts import App, Group, Parameter from qodev_gitlab_api import APIError, AuthenticationError, ConfigurationError, NotFoundError @@ -32,141 +30,15 @@ from qodev_gitlab_cli.commands.releases import releases_app # noqa: E402 from qodev_gitlab_cli.commands.variables import variables_app # noqa: E402 -app.command(projects_app) -app.command(mrs_app) -app.command(pipelines_app) -app.command(jobs_app) -app.command(issues_app) -app.command(releases_app) -app.command(variables_app) +_sub_apps = [projects_app, mrs_app, pipelines_app, jobs_app, issues_app, releases_app, variables_app] -# Prevent the command reference epilogue from showing on sub-command help pages. -for _sub in (projects_app, mrs_app, pipelines_app, jobs_app, issues_app, releases_app, variables_app): - _sub.help_epilogue = "" +for _sub in _sub_apps: + app.command(_sub) + _sub.help_epilogue = "" # prevent epilogue from propagating to sub-command help +from qodev_gitlab_cli.help_reference import build_command_reference # noqa: E402 -# --------------------------------------------------------------------------- -# Dynamic command reference for root --help -# --------------------------------------------------------------------------- -def _is_bool_type(tp: type | None) -> bool: - if tp is bool: - return True - if tp is None: - return False - origin = get_origin(tp) - if origin is Union or isinstance(tp, types.UnionType): - return bool in get_args(tp) - return False - - -def _format_signature(func: object, prefix_len: int = 0, col_width: int = 50) -> str: - sig = inspect.signature(func) # type: ignore[arg-type] - try: - hints = get_type_hints(func, include_extras=True) - except Exception: - hints = {} - - required: list[str] = [] - optional: list[str] = [] - for pname, param in sig.parameters.items(): - hint = hints.get(pname) - - cli_param = None - base_type = hint - if hint is not None and get_origin(hint) is Annotated: - args = get_args(hint) - base_type = args[0] - for arg in args[1:]: - if isinstance(arg, Parameter): - cli_param = arg - break - - is_bool = _is_bool_type(base_type) - has_default = param.default is not inspect.Parameter.empty - - if param.kind == param.KEYWORD_ONLY: - if cli_param and cli_param.name: - names = cli_param.name if isinstance(cli_param.name, (list, tuple)) else [cli_param.name] - cli_name = names[0] - else: - cli_name = f"--{pname.replace('_', '-')}" - - if has_default: - optional.append(f"\\[{cli_name}]") - elif is_bool: - required.append(cli_name) - else: - required.append(f"{cli_name} {pname.upper()}") - elif param.kind in (param.POSITIONAL_ONLY, param.POSITIONAL_OR_KEYWORD): - label = pname.upper() - if has_default: - optional.append(f"\\[{label}]") - else: - required.append(label) - - # Progressively drop optional args from the end to fit column width - max_sig = col_width - prefix_len - 2 # 2 spaces gap before description - parts = required + optional - result = " ".join(parts) - dropped = 0 - while len(result) > max_sig and optional: - optional.pop() - dropped += 1 - parts = required + optional + (["..."] if dropped else []) - result = " ".join(parts) - - return result - - -def _display_len(s: str) -> int: - """Return the rendered width (Rich \\[ escapes become [).""" - return len(s.replace("\\[", "[")) - - -def _build_command_reference() -> str: - sub_apps = [ - projects_app, mrs_app, pipelines_app, jobs_app, - issues_app, releases_app, variables_app, - ] - - col_width = 46 - entries: list[tuple[str, str]] = [] - for sub in sub_apps: - sub_name = sub.name[0] - for cmd_name, cmd_app in sub._commands.items(): - if cmd_name.startswith("-"): - continue - func = cmd_app.default_command - if func is None: - continue - prefix = f" {sub_name} {cmd_name} " - sig_str = _format_signature(func, prefix_len=len(prefix), col_width=col_width) - doc = (func.__doc__ or "").strip().split("\n")[0] - left = f" {sub_name} {cmd_name}" - if sig_str: - left += f" {sig_str}" - entries.append((left, doc)) - entries.append(("", "")) - - if entries and entries[-1] == ("", ""): - entries.pop() - - max_line = 78 - lines = ["All Commands:\n"] - for left, doc in entries: - if not left: - lines.append("") - else: - display_w = _display_len(left) - pad = max(2, col_width - display_w) - avail = max_line - display_w - pad - if len(doc) > avail > 10: - doc = doc[: avail - 1] + "\u2026" - lines.append(f"{left}{' ' * pad}{doc}") - return "\n".join(lines) - - -app.help_epilogue = _build_command_reference() +app.help_epilogue = build_command_reference(_sub_apps) # --------------------------------------------------------------------------- # Exit codes diff --git a/src/qodev_gitlab_cli/help_reference.py b/src/qodev_gitlab_cli/help_reference.py new file mode 100644 index 0000000..5cb01c7 --- /dev/null +++ b/src/qodev_gitlab_cli/help_reference.py @@ -0,0 +1,123 @@ +"""Build a flat command reference for the root --help epilogue.""" + +from __future__ import annotations + +import inspect +import types +from typing import Annotated, Any, Union, get_args, get_origin, get_type_hints + +from cyclopts import App, Parameter + +COL_WIDTH = 46 +MAX_LINE = 78 +MIN_DESC_WIDTH = 10 +ELLIPSIS = "\u2026" + + +def _is_bool_type(tp: object) -> bool: + if tp is bool: + return True + if tp is None: + return False + origin = get_origin(tp) + if origin is Union or isinstance(tp, types.UnionType): + return bool in get_args(tp) + return False + + +def _display_len(s: str) -> int: + """Return rendered width (Rich \\\\[ escapes render as single [).""" + return len(s.replace("\\[", "[")) + + +def _format_signature(func: Any, prefix_len: int, col_width: int) -> str: + sig = inspect.signature(func) + try: + hints = get_type_hints(func, include_extras=True) + except (TypeError, NameError): + hints = {} + + required: list[str] = [] + optional: list[str] = [] + for pname, param in sig.parameters.items(): + hint = hints.get(pname) + + cli_param = None + base_type = hint + if hint is not None and get_origin(hint) is Annotated: + args = get_args(hint) + base_type = args[0] + for arg in args[1:]: + if isinstance(arg, Parameter): + cli_param = arg + break + + is_bool = _is_bool_type(base_type) + has_default = param.default is not inspect.Parameter.empty + + if param.kind == param.KEYWORD_ONLY: + if cli_param and cli_param.name: + names = cli_param.name if isinstance(cli_param.name, (list, tuple)) else [cli_param.name] + cli_name = names[0] + else: + cli_name = f"--{pname.replace('_', '-')}" + + if has_default: + optional.append(f"\\[{cli_name}]") + elif is_bool: + required.append(cli_name) + else: + required.append(f"{cli_name} {pname.upper()}") + elif param.kind in (param.POSITIONAL_ONLY, param.POSITIONAL_OR_KEYWORD): + label = pname.upper() + if has_default: + optional.append(f"\\[{label}]") + else: + required.append(label) + + max_sig = col_width - prefix_len - 2 + parts = required + optional + result = " ".join(parts) + while len(result) > max_sig and optional: + optional.pop() + parts = required + optional + ["..."] + result = " ".join(parts) + + return result + + +def build_command_reference(sub_apps: list[App]) -> str: + """Walk sub-apps and build a formatted "All Commands" block.""" + entries: list[tuple[str, str]] = [] + for sub in sub_apps: + sub_name = sub.name[0] if sub.name else "?" + for cmd_name, cmd_app in sub.resolved_commands().items(): + if cmd_name.startswith("-"): + continue + func = cmd_app.default_command + if func is None: + continue + prefix = f" {sub_name} {cmd_name} " + sig_str = _format_signature(func, prefix_len=len(prefix), col_width=COL_WIDTH) + doc = (func.__doc__ or "").strip().split("\n")[0] + left = f" {sub_name} {cmd_name}" + if sig_str: + left += f" {sig_str}" + entries.append((left, doc)) + entries.append(("", "")) + + if entries and entries[-1] == ("", ""): + entries.pop() + + lines = ["All Commands:\n"] + for left, doc in entries: + if not left: + lines.append("") + else: + display_w = _display_len(left) + pad = max(2, COL_WIDTH - display_w) + avail = MAX_LINE - display_w - pad + if len(doc) > avail > MIN_DESC_WIDTH: + doc = doc[: avail - 1] + ELLIPSIS + lines.append(f"{left}{' ' * pad}{doc}") + return "\n".join(lines)