From 61839e0d139717a09da98e8c31b3b4c68183e36e Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Sun, 26 Jul 2026 03:03:01 +0500 Subject: [PATCH] fix(extensions): hyphenate command names in 'extension info' listing The 'Commands:' section of 'specify extension info' for a locally installed extension printed each command in its manifest dotted form (e.g. speckit.jira.sync). Cline and Forge register hyphenated command names (/speckit-jira-sync), so on those projects the displayed names did not match what the user actually invokes. Format each name through the active agent's command-name formatter, mirroring the parity 'extension add' already applies to its 'Provided commands' listing (#3669) and completing the Forge/Cline command-name parity from #3641/#3642. Adds a regression test asserting the hyphenated form appears (and the dotted form does not) for a Forge project. --- src/specify_cli/extensions/_commands.py | 23 ++++++++++- tests/test_extensions.py | 53 +++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index 7ae4f8e9f0..8b6281e879 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -879,9 +879,30 @@ def extension_info( console.print() if ext_manifest.commands: + # Print each command the way the active agent registers it. + # Cline and Forge hyphenate command names (e.g. Forge invokes + # `/speckit-jira-sync`, not the manifest's dotted + # `speckit.jira.sync`), so mirror the same formatting used by + # `extension add`'s "Provided commands" listing — otherwise the + # names shown here don't match what the user actually types. + selected_ai = load_init_options(project_root).get("ai") + if selected_ai == "cline": + from specify_cli.integrations.cline import ( + format_cline_command_name as _format_command_name, + ) + elif selected_ai == "forge": + from specify_cli.integrations.forge import ( + format_forge_command_name as _format_command_name, + ) + else: + _format_command_name = None + console.print("[bold]Commands:[/bold]") for cmd in ext_manifest.commands: - console.print(f" • {_escape_markup(str(cmd['name']))}: {_escape_markup(str(cmd.get('description', '')))}") + cmd_name = cmd['name'] + if _format_command_name is not None: + cmd_name = _format_command_name(cmd_name) + console.print(f" • {_escape_markup(str(cmd_name))}: {_escape_markup(str(cmd.get('description', '')))}") console.print() # Show catalog status diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 61826aad5b..a6d7faa71e 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -9115,3 +9115,56 @@ def test_forge_extension_install_listing_hyphenates_command_names( # Forge registers hyphenated command names, so the summary must match. assert "speckit-test-ext-hello" in result.output assert "speckit.test-ext.hello" not in result.output + + +def test_forge_extension_info_hyphenates_command_names( + extension_dir, project_dir, monkeypatch +): + """`extension info` for an installed extension must show hyphenated + /speckit- command names on a Forge project, matching the names Forge + actually registers — the same parity `extension add`'s listing already has. + """ + import io + import json + import os + + from rich.console import Console + + from specify_cli.extensions import _commands + + init_options = project_dir / ".specify" / "init-options.json" + init_options.write_text(json.dumps({"ai": "forge", "script": "sh"})) + + manager = ExtensionManager(project_dir) + manager.install_from_directory( + extension_dir, "1.0.0", register_commands=False + ) + + # Force the "installed locally, not in catalog" branch (the one that prints + # the local manifest's Commands section) and avoid any network catalog + # lookup. + monkeypatch.setattr( + _commands, "_resolve_catalog_extension", lambda *a, **k: (None, None) + ) + + # Call the handler directly against a plain captured Console. (Driving it + # through CliRunner reformats output via Rich's live console, which + # recurses under pytest's captured stdout — unrelated to this code path.) + buf = io.StringIO() + original_console = _commands.console + _commands.console = Console(file=buf, force_terminal=False, width=200) + old_cwd = os.getcwd() + try: + os.chdir(project_dir) + _commands.extension_info("test-ext") + except SystemExit: + pass + finally: + os.chdir(old_cwd) + _commands.console = original_console + + output = buf.getvalue() + # The Commands section must render the hyphenated form Forge registers, + # not the manifest's dotted name. + assert "speckit-test-ext-hello" in output, output + assert "speckit.test-ext.hello" not in output, output