From 3320cac0a5c4a54c7386a8d12913a68d2970515e Mon Sep 17 00:00:00 2001 From: tkv00 Date: Thu, 15 Oct 2026 09:14:20 +0900 Subject: [PATCH 1/6] test(extensions): cover command names listed for each integration `extension add` and `extension info` list an extension's commands, but only Cline and Forge get the names they register. Claude, Codex, Junie and Copilot in skills mode register `speckit--`, yet both listings print the manifest's dotted `speckit..`. Add one case table shared by both listing tests: the four agents above, Cline and Forge (already correct), and Copilot in commands mode and Gemini, which register dotted names and must keep them. On main the first four cases fail for both commands. Assisted-by: Claude Code (model: Claude Opus 5.5, autonomous) Co-Authored-By: Claude Opus 5.5 --- tests/specify_cli/extensions/_helpers.py | 24 +++++++++ .../extensions/test_command_add.py | 30 +++++++++++ .../extensions/test_command_info.py | 51 +++++++++++++++++++ 3 files changed, 105 insertions(+) diff --git a/tests/specify_cli/extensions/_helpers.py b/tests/specify_cli/extensions/_helpers.py index ef1bd845d2..3380c9748c 100644 --- a/tests/specify_cli/extensions/_helpers.py +++ b/tests/specify_cli/extensions/_helpers.py @@ -4,9 +4,33 @@ import os from pathlib import Path +import pytest + MINIMAL_ZIP_BYTES = b"PK\x05\x06" + b"\x00" * 18 +# The ``extension_dir`` fixture provides ``speckit.test-ext.hello``. Each case +# pairs an agent's init options with the name that agent registers on disk +# (what the user types) and the other spelling, which must not be listed. +_HYPHENATED = "speckit-test-ext-hello" +_DOTTED = "speckit.test-ext.hello" +LISTED_COMMAND_NAME_CASES = [ + # .claude/skills/speckit-test-ext-hello/SKILL.md + pytest.param({"ai": "claude", "ai_skills": True}, _HYPHENATED, _DOTTED, id="claude"), + # .agents/skills/speckit-test-ext-hello/SKILL.md + pytest.param({"ai": "codex", "ai_skills": True}, _HYPHENATED, _DOTTED, id="codex"), + # .junie/commands/speckit-test-ext-hello.md + pytest.param({"ai": "junie"}, _HYPHENATED, _DOTTED, id="junie"), + # .github/skills/speckit-test-ext-hello/SKILL.md + pytest.param({"ai": "copilot", "ai_skills": True}, _HYPHENATED, _DOTTED, id="copilot-skills"), + pytest.param({"ai": "cline"}, _HYPHENATED, _DOTTED, id="cline"), + pytest.param({"ai": "forge"}, _HYPHENATED, _DOTTED, id="forge"), + # .github/agents/speckit.test-ext.hello.agent.md + pytest.param({"ai": "copilot"}, _DOTTED, _HYPHENATED, id="copilot-commands"), + # .gemini/commands/speckit.test-ext.hello.toml + pytest.param({"ai": "gemini"}, _DOTTED, _HYPHENATED, id="gemini"), +] + def open_test_download_zip(project_root, download_dir, zip_filename): """Create a transient download file using platform-appropriate semantics.""" diff --git a/tests/specify_cli/extensions/test_command_add.py b/tests/specify_cli/extensions/test_command_add.py index cddcd6bf21..9b115fe87c 100644 --- a/tests/specify_cli/extensions/test_command_add.py +++ b/tests/specify_cli/extensions/test_command_add.py @@ -28,6 +28,7 @@ ) from tests.conftest import strip_ansi from tests.specify_cli.extensions._helpers import ( + LISTED_COMMAND_NAME_CASES, MINIMAL_ZIP_BYTES as _MINIMAL_ZIP_BYTES, can_create_symlink, open_test_download_zip as _open_test_download_zip, @@ -1268,3 +1269,32 @@ 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 + + +@pytest.mark.parametrize( + ("init_options", "listed", "not_listed"), LISTED_COMMAND_NAME_CASES +) +def test_extension_install_listing_matches_registered_command_names( + extension_dir, project_dir, init_options, listed, not_listed +): + """The post-install 'Provided commands' listing must show each command the + way the active agent registers it, for every integration rather than only + the agents special-cased by name.""" + init_options_file = project_dir / ".specify" / "init-options.json" + init_options_file.write_text( + json.dumps({**init_options, "script": "sh"}), encoding="utf-8" + ) + + old_cwd = os.getcwd() + try: + os.chdir(project_dir) + result = CliRunner().invoke( + app, ["extension", "add", str(extension_dir), "--dev"] + ) + finally: + os.chdir(old_cwd) + + output = strip_ansi(result.output) + assert result.exit_code == 0, output + assert f"• {listed} - " in output, output + assert f"• {not_listed} - " not in output, output diff --git a/tests/specify_cli/extensions/test_command_info.py b/tests/specify_cli/extensions/test_command_info.py index 696461bbc2..3a4730a225 100644 --- a/tests/specify_cli/extensions/test_command_info.py +++ b/tests/specify_cli/extensions/test_command_info.py @@ -18,6 +18,7 @@ from specify_cli.extensions import ( ExtensionManager, ) +from tests.specify_cli.extensions._helpers import LISTED_COMMAND_NAME_CASES class TestExtensionInfoRendering: @@ -239,3 +240,53 @@ def test_forge_extension_info_hyphenates_command_names( # not the manifest's dotted name. assert "speckit-test-ext-hello" in output, output assert "speckit.test-ext.hello" not in output, output + + +@pytest.mark.parametrize( + ("init_options", "listed", "not_listed"), LISTED_COMMAND_NAME_CASES +) +def test_extension_info_listing_matches_registered_command_names( + extension_dir, project_dir, monkeypatch, init_options, listed, not_listed +): + """`extension info` for an installed extension must show each command the + way the active agent registers it, for every integration rather than only + the agents special-cased by name.""" + + from rich.console import Console + + from specify_cli.extensions import _commands + + init_options_file = project_dir / ".specify" / "init-options.json" + init_options_file.write_text( + json.dumps({**init_options, "script": "sh"}), encoding="utf-8" + ) + + manager = ExtensionManager(project_dir) + manager.install_from_directory( + extension_dir, "1.0.0", register_commands=False + ) + + # Force the "installed locally, not in catalog" branch, which prints the + # local manifest's Commands section, and avoid any network catalog lookup. + monkeypatch.setattr( + _commands, "_resolve_catalog_extension", lambda *a, **k: (None, None) + ) + + buf = io.StringIO() + monkeypatch.setattr( + _commands, "console", Console(file=buf, force_terminal=False, width=200) + ) + # Restore the cwd before the temp_dir fixture removes the project; Windows + # cannot delete the current working directory. + old_cwd = os.getcwd() + try: + os.chdir(project_dir) + _commands.extension_info("test-ext") + except SystemExit: + pass + finally: + os.chdir(old_cwd) + + output = buf.getvalue() + assert f"• {listed}: " in output, output + assert f"• {not_listed}: " not in output, output From 4697c223c6d0cf5cc083a8ce0f69050f5dac8bc0 Mon Sep 17 00:00:00 2001 From: tkv00 Date: Thu, 15 Oct 2026 09:14:41 +0900 Subject: [PATCH 2/6] fix(extensions): list commands the way the active agent registers them Both listings special-cased Cline and Forge by name, so every other agent that registers hyphenated names (25 of 41 integrations register them; the listings handled 2) was shown names it does not have. Ask the active integration for its command separator in the project's mode (`invoke_separator_for_mode` with the persisted `ai_skills` flag) and hyphenate with the helper extension skill registration already uses. This is the approach #4910 took for the init next-steps banner. Agents that register dotted names, and unknown agents, keep the manifest name. A registry test checks that every integration's command-mode separator matches the names CommandRegistrar writes, since the listings now rely on that. The extension user guide notes that listed names follow the agent. Assisted-by: Claude Code (model: Claude Opus 5.5, autonomous) Co-Authored-By: Claude Opus 5.5 --- extensions/EXTENSION-USER-GUIDE.md | 2 ++ src/specify_cli/extensions/_commands.py | 25 ++++++++++++++++++++ src/specify_cli/extensions/command_add.py | 15 +----------- src/specify_cli/extensions/command_info.py | 27 +++++----------------- tests/integrations/test_registry.py | 18 +++++++++++++++ 5 files changed, 52 insertions(+), 35 deletions(-) diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md index 0ec33d1cd8..d434d3cbe3 100644 --- a/extensions/EXTENSION-USER-GUIDE.md +++ b/extensions/EXTENSION-USER-GUIDE.md @@ -187,6 +187,8 @@ Provided commands: Check: .specify/extensions/jira/ ``` +Command names are listed the way your agent registers them. Skills-based integrations such as Claude Code and Codex, and agents such as Cline, Forge, and Junie, show hyphenated names (for example `speckit-jira-specstoissues`). + ### Automatic Agent Skill Registration If your project uses a skills-based integration (e.g., `--integration claude`, `--integration codex`) or was initialized with `--integration-options="--skills"`, extension commands are **automatically registered as agent skills** during installation. This ensures that extensions are discoverable by agents that use the [agentskills.io](https://agentskills.io) skill specification. diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index ce6d229878..cb434e8877 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -121,6 +121,31 @@ def _refresh_events_and_warn(project_root: Path) -> None: console.print(f" {key}: {_escape_markup(detail)}") +def _registered_command_name(project_root: Path, command_name: str) -> str: + """Return *command_name* the way the project's active agent registers it. + + Agents whose command references use ``-`` in the project's current mode + (skills layouts, and command layouts such as Cline, Forge, and Junie) + register ``speckit..`` as ``speckit--``; the others + keep the manifest's dotted name. Deriving this from the integration keeps + command listings in step with registration instead of a list of agents. + """ + from .._init_options import is_ai_skills_enabled + from ..integrations import get_integration + from . import ExtensionManager + + opts = load_init_options(project_root) + selected_ai = opts.get("ai") if isinstance(opts, dict) else None + integration = ( + get_integration(selected_ai) if isinstance(selected_ai, str) else None + ) + if integration is None: + return command_name + if integration.invoke_separator_for_mode(is_ai_skills_enabled(opts)) != "-": + return command_name + return ExtensionManager._skill_name_for_command(command_name) + + def install_extension_from_url( manager, project_root: Path, diff --git a/src/specify_cli/extensions/command_add.py b/src/specify_cli/extensions/command_add.py index 30a4ff717c..1917663406 100644 --- a/src/specify_cli/extensions/command_add.py +++ b/src/specify_cli/extensions/command_add.py @@ -297,22 +297,9 @@ def extension_add( for warning in manifest.warnings: console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {_escape_markup(str(warning))}") - selected_ai = _commands.load_init_options(project_root).get("ai") - is_cline = selected_ai == "cline" - is_forge = selected_ai == "forge" - - if is_cline: - from specify_cli.integrations.cline import format_cline_command_name - if is_forge: - from specify_cli.integrations.forge import format_forge_command_name - console.print("\n[bold cyan]Provided commands:[/bold cyan]") for cmd in manifest.commands: - cmd_name = cmd['name'] - if is_cline: - cmd_name = format_cline_command_name(cmd_name) - elif is_forge: - cmd_name = format_forge_command_name(cmd_name) + cmd_name = _commands._registered_command_name(project_root, cmd['name']) console.print(f" • {_escape_markup(str(cmd_name))} - {_escape_markup(str(cmd.get('description', '')))}") # Report agent skills registration diff --git a/src/specify_cli/extensions/command_info.py b/src/specify_cli/extensions/command_info.py index c0c6d31cc7..dfc685caa2 100644 --- a/src/specify_cli/extensions/command_info.py +++ b/src/specify_cli/extensions/command_info.py @@ -123,29 +123,14 @@ def extension_info( _commands.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 = _commands.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 - + # Print each command the way the active agent registers it + # (e.g. Codex registers `speckit-jira-sync`, not the manifest's + # dotted `speckit.jira.sync`), matching `extension add`'s + # "Provided commands" listing, so the names shown here are the + # ones the user actually types. _commands.console.print("[bold]Commands:[/bold]") for cmd in ext_manifest.commands: - cmd_name = cmd['name'] - if _format_command_name is not None: - cmd_name = _format_command_name(cmd_name) + cmd_name = _commands._registered_command_name(project_root, cmd['name']) _commands.console.print(f" • {_escape_markup(str(cmd_name))}: {_escape_markup(str(cmd.get('description', '')))}") _commands.console.print() diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py index c7cb4d2f3d..d52a644d0c 100644 --- a/tests/integrations/test_registry.py +++ b/tests/integrations/test_registry.py @@ -162,6 +162,24 @@ def test_no_stale_cursor_shorthand(self): from specify_cli.agents import CommandRegistrar assert "cursor" not in CommandRegistrar.AGENT_CONFIGS + @pytest.mark.parametrize( + "key", sorted(k for k in INTEGRATION_REGISTRY if k != "generic") + ) + def test_command_mode_separator_matches_registered_names(self, key): + """A ``-`` command-mode separator must mean hyphenated registration. + + Extension listings derive the command names they show from the + separator, so an integration that registered dotted names under ``-`` + (or the reverse) would list commands the user cannot invoke. + """ + from specify_cli.agents import CommandRegistrar + + registered = CommandRegistrar._compute_output_name( + key, "speckit.ext.cmd", CommandRegistrar.AGENT_CONFIGS[key] + ) + hyphenated = INTEGRATION_REGISTRY[key].invoke_separator_for_mode(False) == "-" + assert registered == ("speckit-ext-cmd" if hyphenated else "speckit.ext.cmd") + class TestMultiInstallSafeContracts: """Declared safe integrations must stay isolated from each other.""" From 7dd86aa390261622cd661dd3a239ed3c8afd6eb8 Mon Sep 17 00:00:00 2001 From: tkv00 Date: Fri, 16 Oct 2026 00:16:35 +0900 Subject: [PATCH 3/6] test(extensions): cover listed command names in generic projects Generic registers extension commands as speckit--/SKILL.md skills with --skills, and as dotted speckit...md files without it. Add both layouts to the listing case table. A shared helper records the init options and, for generic, sets up the integration with --commands-dir, which generic needs before it can register extension commands. On the previous head the --skills case fails for both listings. Assisted-by: Claude Code (model: Claude Opus 5.5, autonomous) Co-Authored-By: Claude Opus 5.5 --- tests/specify_cli/extensions/_helpers.py | 35 +++++++++++++++++++ .../extensions/test_command_add.py | 6 ++-- .../extensions/test_command_info.py | 10 +++--- 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/tests/specify_cli/extensions/_helpers.py b/tests/specify_cli/extensions/_helpers.py index 3380c9748c..745fb85c3d 100644 --- a/tests/specify_cli/extensions/_helpers.py +++ b/tests/specify_cli/extensions/_helpers.py @@ -1,11 +1,16 @@ """Shared helpers for extension command tests.""" from __future__ import annotations +import json import os from pathlib import Path import pytest +from specify_cli.integration_state import write_integration_json +from specify_cli.integrations import get_integration +from specify_cli.integrations.manifest import IntegrationManifest + MINIMAL_ZIP_BYTES = b"PK\x05\x06" + b"\x00" * 18 @@ -29,9 +34,39 @@ pytest.param({"ai": "copilot"}, _DOTTED, _HYPHENATED, id="copilot-commands"), # .gemini/commands/speckit.test-ext.hello.toml pytest.param({"ai": "gemini"}, _DOTTED, _HYPHENATED, id="gemini"), + # /speckit-test-ext-hello/SKILL.md + pytest.param({"ai": "generic", "ai_skills": True}, _HYPHENATED, _DOTTED, id="generic-skills"), + # /speckit.test-ext.hello.md + pytest.param({"ai": "generic"}, _DOTTED, _HYPHENATED, id="generic-commands"), ] +def write_listing_project_options(project_dir, init_options): + """Record *init_options* as the project's init options for a listing case. + + Generic resolves its output directory from ``integration.json`` and + registers extension commands next to its core commands, so its cases also + set up the integration the way ``specify init`` does. + """ + (project_dir / ".specify" / "init-options.json").write_text( + json.dumps({**init_options, "script": "sh"}), encoding="utf-8" + ) + if init_options["ai"] == "generic": + parsed_options = { + "commands_dir": ".myagent/commands", + "skills": bool(init_options.get("ai_skills")), + } + manifest = IntegrationManifest("generic", project_dir) + get_integration("generic").setup( + project_dir, manifest, parsed_options=parsed_options + ) + manifest.save() + write_integration_json( + project_dir, version="1.0.0", integration_key="generic", + settings={"generic": {"parsed_options": parsed_options}}, + ) + + def open_test_download_zip(project_root, download_dir, zip_filename): """Create a transient download file using platform-appropriate semantics.""" target = download_dir / zip_filename diff --git a/tests/specify_cli/extensions/test_command_add.py b/tests/specify_cli/extensions/test_command_add.py index 9b115fe87c..44423b5fdc 100644 --- a/tests/specify_cli/extensions/test_command_add.py +++ b/tests/specify_cli/extensions/test_command_add.py @@ -33,6 +33,7 @@ can_create_symlink, open_test_download_zip as _open_test_download_zip, validate_safe_cache_dir as _validate_safe_cache_dir_test_stand_in, + write_listing_project_options, ) @@ -1280,10 +1281,7 @@ def test_extension_install_listing_matches_registered_command_names( """The post-install 'Provided commands' listing must show each command the way the active agent registers it, for every integration rather than only the agents special-cased by name.""" - init_options_file = project_dir / ".specify" / "init-options.json" - init_options_file.write_text( - json.dumps({**init_options, "script": "sh"}), encoding="utf-8" - ) + write_listing_project_options(project_dir, init_options) old_cwd = os.getcwd() try: diff --git a/tests/specify_cli/extensions/test_command_info.py b/tests/specify_cli/extensions/test_command_info.py index 3a4730a225..c8618b5328 100644 --- a/tests/specify_cli/extensions/test_command_info.py +++ b/tests/specify_cli/extensions/test_command_info.py @@ -18,7 +18,10 @@ from specify_cli.extensions import ( ExtensionManager, ) -from tests.specify_cli.extensions._helpers import LISTED_COMMAND_NAME_CASES +from tests.specify_cli.extensions._helpers import ( + LISTED_COMMAND_NAME_CASES, + write_listing_project_options, +) class TestExtensionInfoRendering: @@ -256,10 +259,7 @@ def test_extension_info_listing_matches_registered_command_names( from specify_cli.extensions import _commands - init_options_file = project_dir / ".specify" / "init-options.json" - init_options_file.write_text( - json.dumps({**init_options, "script": "sh"}), encoding="utf-8" - ) + write_listing_project_options(project_dir, init_options) manager = ExtensionManager(project_dir) manager.install_from_directory( From a93af0bc3d300cfc9cbc03ee9e995dc03b93a354 Mon Sep 17 00:00:00 2001 From: tkv00 Date: Fri, 16 Oct 2026 00:16:36 +0900 Subject: [PATCH 4/6] fix(extensions): hyphenate listed commands in generic skills projects The listings asked invoke_separator_for_mode() for the project's mode. Generic does not override that hook, so it returns "." for both of its layouts, and a generic --skills project listed speckit.. while its skills are registered as speckit--. Hyphenate whenever the persisted ai_skills flag is set, since extension skill registration always uses that name, and consult the separator only in command mode. The hook itself is unchanged because preset skill rendering also reads it. The extension user guide now mentions projects initialized with --skills. Assisted-by: Claude Code (model: Claude Opus 5.5, autonomous) Co-Authored-By: Claude Opus 5.5 --- extensions/EXTENSION-USER-GUIDE.md | 2 +- src/specify_cli/extensions/_commands.py | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md index d434d3cbe3..04b5e1edc0 100644 --- a/extensions/EXTENSION-USER-GUIDE.md +++ b/extensions/EXTENSION-USER-GUIDE.md @@ -187,7 +187,7 @@ Provided commands: Check: .specify/extensions/jira/ ``` -Command names are listed the way your agent registers them. Skills-based integrations such as Claude Code and Codex, and agents such as Cline, Forge, and Junie, show hyphenated names (for example `speckit-jira-specstoissues`). +Command names are listed the way your agent registers them. Skills-based integrations such as Claude Code and Codex, projects initialized with `--integration-options="--skills"`, and agents such as Cline, Forge, and Junie show hyphenated names (for example `speckit-jira-specstoissues`). ### Automatic Agent Skill Registration diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index cb434e8877..d9c1685f36 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -124,9 +124,9 @@ def _refresh_events_and_warn(project_root: Path) -> None: def _registered_command_name(project_root: Path, command_name: str) -> str: """Return *command_name* the way the project's active agent registers it. - Agents whose command references use ``-`` in the project's current mode - (skills layouts, and command layouts such as Cline, Forge, and Junie) - register ``speckit..`` as ``speckit--``; the others + Skills projects register extension commands as ``speckit--`` + skills. In command mode, agents whose command references use ``-`` (such + as Cline, Forge, and Junie) register the same hyphenated name; the others keep the manifest's dotted name. Deriving this from the integration keeps command listings in step with registration instead of a list of agents. """ @@ -141,7 +141,11 @@ def _registered_command_name(project_root: Path, command_name: str) -> str: ) if integration is None: return command_name - if integration.invoke_separator_for_mode(is_ai_skills_enabled(opts)) != "-": + # Generic's command-mode separator applies to both of its layouts, so + # the separator alone cannot tell that a skills project hyphenates. + if not is_ai_skills_enabled(opts) and ( + integration.invoke_separator_for_mode(False) != "-" + ): return command_name return ExtensionManager._skill_name_for_command(command_name) From 5d7a45e5bbc3dcba7cfab9df679ba78248ada7d9 Mon Sep 17 00:00:00 2001 From: tkv00 Date: Fri, 16 Oct 2026 01:13:31 +0900 Subject: [PATCH 5/6] test(integrations): cover extension info for an unloaded adapter `extension info` does not load installed adapters, so in a fresh process get_integration() returns None for a catalog-installed adapter. Install the sample adapter in its skills and command layouts, add an extension, unload the adapter, and check that `extension info` lists the names `extension add` registered and leaves the adapter unloaded. On the previous head the skills case lists speckit... Assisted-by: Claude Code (model: Claude Opus 5.5, autonomous) Co-Authored-By: Claude Opus 5.5 --- .../integrations/test_installed_adapters.py | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/specify_cli/integrations/test_installed_adapters.py b/tests/specify_cli/integrations/test_installed_adapters.py index 7e73fab45a..6f8fbadd92 100644 --- a/tests/specify_cli/integrations/test_installed_adapters.py +++ b/tests/specify_cli/integrations/test_installed_adapters.py @@ -4679,3 +4679,52 @@ def test_forced_package_recovery_still_rejects_symlinked_ancestors(tmp_path, ser assert "symlinked" in result.output.lower() assert packages.is_symlink() assert snapshot(outside) == before + + +@pytest.mark.parametrize(("flavor", "registered", "listed", "not_listed"), [ + ("skills", ".sample-agent/skills/speckit-sample-ext-hello/SKILL.md", + "speckit-sample-ext-hello", "speckit.sample-ext.hello"), + ("commands", ".sample-agent/commands/speckit.sample-ext.hello.md", + "speckit.sample-ext.hello", "speckit-sample-ext-hello"), +], ids=["skills", "commands"]) +def test_extension_info_lists_registered_names_without_loading_adapter( + tmp_path, server, monkeypatch, flavor, registered, listed, not_listed, +): + """A fresh `extension info` leaves the adapter unloaded, yet must still + list the names `extension add` registered for its commands.""" + from specify_cli.extensions import _commands + + extension = tmp_path / "sample-ext" + (extension / "commands").mkdir(parents=True) + (extension / "extension.yml").write_text(yaml.safe_dump({ + "schema_version": "1.0", + "extension": { + "id": "sample-ext", "name": "Sample Extension", "version": "1.0.0", + "description": "Sample extension", + }, + "requires": {"speckit_version": ">=0.1"}, + "provides": {"commands": [{ + "name": "speckit.sample-ext.hello", "file": "commands/hello.md", + "description": "Say hello", + }]}, + })) + (extension / "commands" / "hello.md").write_text( + "---\ndescription: Say hello\n---\nHello\n" + ) + publish(server, code=implementation(flavor=flavor)) + project = catalog_project(tmp_path, server) + install(project) + added = run(project, ["extension", "add", "--dev", str(extension)]) + assert added.exit_code == 0, added.output + assert (project / registered).is_file() + + unload_installed_integrations() + monkeypatch.setattr( + _commands, "_resolve_catalog_extension", lambda *a, **k: (None, None) + ) + info = run(project, ["extension", "info", "sample-ext"]) + + assert info.exit_code == 0, info.output + assert f"• {listed}: " in info.output, info.output + assert f"• {not_listed}: " not in info.output, info.output + assert KEY not in INTEGRATION_REGISTRY From 635e520851b8d1990d27009bce4b2deb27a990d0 Mon Sep 17 00:00:00 2001 From: tkv00 Date: Fri, 16 Oct 2026 01:13:31 +0900 Subject: [PATCH 6/6] fix(extensions): list skill names without loading the adapter The listing helper looked up the integration before checking skills mode. An installed adapter that `extension info` had not loaded fell back to the manifest's dotted name, while its skills are registered as speckit--. Check the persisted ai_skills flag first and hyphenate without a lookup, as extension skill registration does. Only command mode consults the integration, and there an agent that is not loaded keeps the manifest name. Assisted-by: Claude Code (model: Claude Opus 5.5, autonomous) Co-Authored-By: Claude Opus 5.5 --- src/specify_cli/extensions/_commands.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index d9c1685f36..08fe6747fc 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -125,10 +125,12 @@ def _registered_command_name(project_root: Path, command_name: str) -> str: """Return *command_name* the way the project's active agent registers it. Skills projects register extension commands as ``speckit--`` - skills. In command mode, agents whose command references use ``-`` (such - as Cline, Forge, and Junie) register the same hyphenated name; the others - keep the manifest's dotted name. Deriving this from the integration keeps - command listings in step with registration instead of a list of agents. + skills, which needs no integration lookup, so installed adapters stay + unloaded. In command mode, agents whose command references use ``-`` + (such as Cline, Forge, and Junie) register the same hyphenated name; the + others, and agents that are not loaded, keep the manifest's dotted name. + Deriving this from the integration keeps command listings in step with + registration instead of a list of agents. """ from .._init_options import is_ai_skills_enabled from ..integrations import get_integration @@ -136,14 +138,15 @@ def _registered_command_name(project_root: Path, command_name: str) -> str: opts = load_init_options(project_root) selected_ai = opts.get("ai") if isinstance(opts, dict) else None - integration = ( - get_integration(selected_ai) if isinstance(selected_ai, str) else None - ) - if integration is None: + if not isinstance(selected_ai, str) or not selected_ai: return command_name - # Generic's command-mode separator applies to both of its layouts, so - # the separator alone cannot tell that a skills project hyphenates. - if not is_ai_skills_enabled(opts) and ( + # Check skills mode before the integration: generic's command-mode + # separator applies to both of its layouts, and inspection commands such + # as `extension info` do not load installed adapters. + if is_ai_skills_enabled(opts): + return ExtensionManager._skill_name_for_command(command_name) + integration = get_integration(selected_ai) + if integration is None or ( integration.invoke_separator_for_mode(False) != "-" ): return command_name