From 115ad564cc902a28ccf37afa7fa6db738f52981b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 13:41:49 +0000 Subject: [PATCH 1/4] fix(cli): render help at a fixed width regardless of terminal size `check --help` wrapped and truncated the platform choices line in a narrow terminal (40 columns cut "--platform" and the choices list). Follow the daily-releases scripts' Typer pattern: set context_settings={"terminal_width": 800} and rich_markup_mode=None on the root app so Click renders plain, unwrapped help. Rich help sizes itself from TERMINAL_WIDTH/COLUMNS and ignores terminal_width, so both settings are needed. Tighten the platform help test to assert the full choices line under COLUMNS=40. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5rAHJfvZyEEaUQghV7hCQ --- packages/skilllint/plugin_validator.py | 7 ++++++- packages/skilllint/tests/test_cli.py | 7 +++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/skilllint/plugin_validator.py b/packages/skilllint/plugin_validator.py index e64e3833..bccb355b 100644 --- a/packages/skilllint/plugin_validator.py +++ b/packages/skilllint/plugin_validator.py @@ -1822,7 +1822,12 @@ def _validate_with_cache( # ============================================================================= # Create Typer app -app = typer.Typer(help="Validate Claude Code plugins and skills", add_completion=False) +app = typer.Typer( + help="Validate Claude Code plugins and skills", + add_completion=False, + context_settings={"terminal_width": 800}, + rich_markup_mode=None, +) app.add_typer(docs_app, name="docs") # Version option handled via callback diff --git a/packages/skilllint/tests/test_cli.py b/packages/skilllint/tests/test_cli.py index f25659d2..b71d5213 100644 --- a/packages/skilllint/tests/test_cli.py +++ b/packages/skilllint/tests/test_cli.py @@ -836,15 +836,14 @@ def test_check_help_lists_every_registered_platform(self, cli_runner: CliRunner) How: Render ``check --help`` and look for each ADAPTERS key in CLI form Why: Agents are told to read accepted platform names from this help """ - result = cli_runner.invoke(plugin_validator.app, ["check", "--help"]) + # A narrow terminal must not wrap or truncate help: the app fixes terminal_width. + result = cli_runner.invoke(plugin_validator.app, ["check", "--help"], env={"COLUMNS": "40"}) assert result.exit_code == 0 assert plugin_validator.ADAPTERS expected = ", ".join(sorted(plugin_validator.PLATFORM_CLI_IDS)) assert expected == plugin_validator.PLATFORM_CHOICES - assert "Platform adapter. Choices:" in result.output - for choice in expected.split(", "): - assert choice in result.output + assert f"Platform adapter. Choices: {expected}" in result.output def test_hyphenated_third_party_platform_id_resolves_exactly(self, monkeypatch) -> None: """Registered adapter IDs containing hyphens remain directly selectable.""" From 9459353634ea59ab39999a41b8daedebb9f9db18 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 13:44:12 +0000 Subject: [PATCH 2/4] fix(cli): disable Typer pretty exceptions on the root app Complete the Typer app pattern used by the daily-releases, receiving-pr-reviews and create-merge-request-changelog skill scripts in claude_skills: pretty_exceptions_enable=False alongside the fixed terminal_width and rich_markup_mode=None, so tracebacks are plain and not wrapped to the terminal width either. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5rAHJfvZyEEaUQghV7hCQ --- packages/skilllint/plugin_validator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/skilllint/plugin_validator.py b/packages/skilllint/plugin_validator.py index bccb355b..40b33b57 100644 --- a/packages/skilllint/plugin_validator.py +++ b/packages/skilllint/plugin_validator.py @@ -1827,6 +1827,7 @@ def _validate_with_cache( add_completion=False, context_settings={"terminal_width": 800}, rich_markup_mode=None, + pretty_exceptions_enable=False, ) app.add_typer(docs_app, name="docs") From e708fc241fd4c3c089a2c1e78752822d9609cfbc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 14:30:06 +0000 Subject: [PATCH 3/4] docs(cli): source the 800-column help width State why terminal_width is fixed, how Click uses it (typer/_click/formatting.py), and that 800 matches the Typer apps in claude_skills, per the repository's no-invented-constraints rule. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5rAHJfvZyEEaUQghV7hCQ --- packages/skilllint/plugin_validator.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/skilllint/plugin_validator.py b/packages/skilllint/plugin_validator.py index 40b33b57..c9d50e25 100644 --- a/packages/skilllint/plugin_validator.py +++ b/packages/skilllint/plugin_validator.py @@ -1825,6 +1825,11 @@ def _validate_with_cache( app = typer.Typer( help="Validate Claude Code plugins and skills", add_completion=False, + # Help is read by agents, so it must not wrap or truncate to the caller's + # terminal. Click uses terminal_width as the exact help width (otherwise + # min(terminal columns, 80) - 2; typer/_click/formatting.py). 800 matches the + # Typer apps in claude_skills (daily-releases, receiving-pr-reviews, + # create-merge-request-changelog); lines longer than 800 columns still wrap. context_settings={"terminal_width": 800}, rich_markup_mode=None, pretty_exceptions_enable=False, From 00fa70039fe5c07d715195cb88e51703a40bfd98 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 03:04:21 +0000 Subject: [PATCH 4/4] fix(cli): keep docstring sections and drop dead Rich help settings Address review findings on the fixed-width help change: - Click re-wraps help paragraphs, so Args:/Raises: docstring sections ran together on one line once rich_markup_mode=None applied. Mark those paragraphs with Click's \b no-rewrap marker (D301 noqa: ruff's autofix would add an r prefix and silently break the marker). - The root app's rich_markup_mode=None governs every sub-app, so docs_app's rich_markup_mode="rich" and the rich_help_panel arguments did nothing. Help output is byte-identical across all 11 screens without them; remove them. - State the real default help width, including its 50-column floor (typer/_click/formatting.py), in the terminal_width comment. - Test that every help screen (root, check, rule, rules, docs, docs fetch) renders identically at 40 and 200 columns, which also covers the docs sub-app inheriting terminal_width, and that each docstring section keeps its own lines. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01K5rAHJfvZyEEaUQghV7hCQ --- packages/skilllint/cli_docs.py | 44 +++++++------------- packages/skilllint/plugin_validator.py | 11 ++--- packages/skilllint/tests/test_cli.py | 56 ++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 34 deletions(-) diff --git a/packages/skilllint/cli_docs.py b/packages/skilllint/cli_docs.py index f121858a..b2e2d121 100644 --- a/packages/skilllint/cli_docs.py +++ b/packages/skilllint/cli_docs.py @@ -38,10 +38,7 @@ # --------------------------------------------------------------------------- docs_app = typer.Typer( - help="Fetch, query, and verify cached vendor documentation.", - add_completion=False, - no_args_is_help=True, - rich_markup_mode="rich", + help="Fetch, query, and verify cached vendor documentation.", add_completion=False, no_args_is_help=True ) @@ -71,18 +68,10 @@ def _format_status_label(status: CacheStatus) -> str: def fetch( url: Annotated[str, typer.Argument(help="Documentation URL to fetch or serve from cache.")], ttl: Annotated[ - float, - typer.Option( - "--ttl", help="Cache time-to-live in hours before a refresh is attempted.", rich_help_panel="Cache Options" - ), + float, typer.Option("--ttl", help="Cache time-to-live in hours before a refresh is attempted.") ] = 4.0, force: Annotated[ - bool, - typer.Option( - "--force", - help="Skip the freshness check and always attempt a network fetch.", - rich_help_panel="Cache Options", - ), + bool, typer.Option("--force", help="Skip the freshness check and always attempt a network fetch.") ] = False, ) -> None: """Fetch a documentation page or return a cached copy. @@ -90,9 +79,10 @@ def fetch( Prints the cached file path to stdout so agents can capture it. Status information is written to stderr. + \b Raises: typer.Exit: Exit code 1 when no cache exists and network is unavailable. - """ + """ # noqa: D301 try: result = fetch_or_cached(url, ttl_hours=ttl, force=force) except NoCacheError as exc: @@ -122,28 +112,21 @@ def fetch( @docs_app.command("fetch-authorities") def fetch_authorities( ttl: Annotated[ - float, - typer.Option( - "--ttl", help="Cache time-to-live in hours before a refresh is attempted.", rich_help_panel="Cache Options" - ), + float, typer.Option("--ttl", help="Cache time-to-live in hours before a refresh is attempted.") ] = 4.0, force: Annotated[ - bool, - typer.Option( - "--force", - help="Skip the freshness check and always attempt a network fetch.", - rich_help_panel="Cache Options", - ), + bool, typer.Option("--force", help="Skip the freshness check and always attempt a network fetch.") ] = False, ) -> None: """Fetch cached documentation for all normalized rule authority URLs. Prints one cached file path per successfully fetched authority URL. + \b Raises: typer.Exit: Exit code 1 when one or more authority URLs cannot be fetched and no stale cache can be served. - """ + """ # noqa: D301 authority_urls = list(iter_authority_urls(unique=True)) if not authority_urls: err_console.print(":warning: [yellow]No authority URLs found in the rule registry[/yellow]") @@ -189,9 +172,10 @@ def latest( Prints the file path to stdout when found. + \b Raises: typer.Exit: Exit code 1 when no cached file exists for the given page name. - """ + """ # noqa: D301 path = find_latest(page_name) if path is None: err_console.print(f":cross_mark: [red]No cached file found for page name:[/red] {page_name}") @@ -229,9 +213,10 @@ def section( Output is written to stdout. + \b Raises: typer.Exit: Exit code 1 when the heading is not found. - """ + """ # noqa: D301 text = read_section(file_path, heading) if text is None: err_console.print(f":cross_mark: [red]Section not found:[/red] {heading!r} in {file_path}") @@ -253,9 +238,10 @@ def verify( Exits 0 when the file is intact, 1 otherwise. + \b Raises: typer.Exit: Exit code 1 when MODIFIED or UNVERIFIABLE. - """ + """ # noqa: D301 result = verify_integrity(file_path) match result.status: diff --git a/packages/skilllint/plugin_validator.py b/packages/skilllint/plugin_validator.py index c9d50e25..d48a6eb2 100644 --- a/packages/skilllint/plugin_validator.py +++ b/packages/skilllint/plugin_validator.py @@ -1826,10 +1826,10 @@ def _validate_with_cache( help="Validate Claude Code plugins and skills", add_completion=False, # Help is read by agents, so it must not wrap or truncate to the caller's - # terminal. Click uses terminal_width as the exact help width (otherwise - # min(terminal columns, 80) - 2; typer/_click/formatting.py). 800 matches the - # Typer apps in claude_skills (daily-releases, receiving-pr-reviews, - # create-merge-request-changelog); lines longer than 800 columns still wrap. + # terminal. Click uses terminal_width as the exact help width; without it the + # width is max(min(terminal columns, 80) - 2, 50) (typer/_click/formatting.py). + # 800 is the width the Typer scripts in the claude_skills repository use for + # the same reason; a help line longer than 800 columns would still wrap. context_settings={"terminal_width": 800}, rich_markup_mode=None, pretty_exceptions_enable=False, @@ -2053,10 +2053,11 @@ def rule_cmd( ) -> None: """Show documentation for a validation rule. + \b Args: rule_id: Rule identifier (e.g., "FM002", "SK004") record: Optional path to write terminal output as SVG or HTML. - """ + """ # noqa: D301 console = _make_rule_console(record=record is not None) _show_rule_doc(rule_id, console=console) _maybe_export_recording(console, record) diff --git a/packages/skilllint/tests/test_cli.py b/packages/skilllint/tests/test_cli.py index b71d5213..dcac8717 100644 --- a/packages/skilllint/tests/test_cli.py +++ b/packages/skilllint/tests/test_cli.py @@ -826,6 +826,62 @@ def test_file_passes_only_when_all_validators_pass( _FIXTURES = Path(__file__).parent / "fixtures" +_HELP_INVOCATIONS = [ + ["--help"], + ["check", "--help"], + ["rule", "--help"], + ["rules", "--help"], + ["docs", "--help"], + ["docs", "fetch", "--help"], +] + + +class TestHelpRendering: + """Help text is plain and does not depend on the caller's terminal.""" + + @pytest.mark.parametrize("args", _HELP_INVOCATIONS, ids=" ".join) + def test_help_is_independent_of_terminal_width(self, cli_runner: CliRunner, args: list[str]) -> None: + """Help renders identically in a 40- and a 200-column terminal. + + Tests: root app terminal_width / rich_markup_mode, inherited by every sub-app + How: Invoke each help screen under COLUMNS=40 and COLUMNS=200 and compare + Why: Agents read help; a narrow terminal must not wrap or truncate it + """ + narrow = cli_runner.invoke(plugin_validator.app, args, env={"COLUMNS": "40"}) + wide = cli_runner.invoke(plugin_validator.app, args, env={"COLUMNS": "200"}) + + assert narrow.exit_code == 0 + assert wide.exit_code == 0 + assert narrow.output == wide.output + + @pytest.mark.parametrize( + ("args", "header", "first_entry"), + [ + (["rule", "--help"], "Args:", "rule_id:"), + (["docs", "fetch", "--help"], "Raises:", "typer.Exit:"), + (["docs", "fetch-authorities", "--help"], "Raises:", "typer.Exit:"), + (["docs", "latest", "--help"], "Raises:", "typer.Exit:"), + (["docs", "section", "--help"], "Raises:", "typer.Exit:"), + (["docs", "verify", "--help"], "Raises:", "typer.Exit:"), + ], + ids=lambda value: " ".join(value) if isinstance(value, list) else value, + ) + def test_docstring_sections_keep_their_line_breaks( + self, cli_runner: CliRunner, args: list[str], header: str, first_entry: str + ) -> None: + """A docstring ``Args:``/``Raises:`` section is not re-wrapped into one paragraph. + + Tests: Click's ``\\b`` no-rewrap marker on command docstrings + How: Render help and look for the section header on its own line, then its first entry + Why: Click re-wraps help paragraphs; unmarked sections run together on one line + """ + result = cli_runner.invoke(plugin_validator.app, args) + + lines = [line.strip() for line in result.output.splitlines()] + assert header in lines, f"{header!r} is not on its own line:\n{result.output}" + assert lines[lines.index(header) + 1].startswith(first_entry) + + class TestPlatformFlag: """Test --platform flag dispatches to the correct adapter."""