diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md index 0ec33d1cd8..04b5e1edc0 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, 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 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..08fe6747fc 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -121,6 +121,38 @@ 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. + + Skills projects register extension commands as ``speckit--`` + 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 + from . import ExtensionManager + + opts = load_init_options(project_root) + selected_ai = opts.get("ai") if isinstance(opts, dict) else None + if not isinstance(selected_ai, str) or not selected_ai: + return command_name + # 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 + 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.""" diff --git a/tests/specify_cli/extensions/_helpers.py b/tests/specify_cli/extensions/_helpers.py index ef1bd845d2..745fb85c3d 100644 --- a/tests/specify_cli/extensions/_helpers.py +++ b/tests/specify_cli/extensions/_helpers.py @@ -1,12 +1,71 @@ """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 +# 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"), + # /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.""" diff --git a/tests/specify_cli/extensions/test_command_add.py b/tests/specify_cli/extensions/test_command_add.py index cddcd6bf21..44423b5fdc 100644 --- a/tests/specify_cli/extensions/test_command_add.py +++ b/tests/specify_cli/extensions/test_command_add.py @@ -28,10 +28,12 @@ ) 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, validate_safe_cache_dir as _validate_safe_cache_dir_test_stand_in, + write_listing_project_options, ) @@ -1268,3 +1270,29 @@ 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.""" + write_listing_project_options(project_dir, init_options) + + 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..c8618b5328 100644 --- a/tests/specify_cli/extensions/test_command_info.py +++ b/tests/specify_cli/extensions/test_command_info.py @@ -18,6 +18,10 @@ from specify_cli.extensions import ( ExtensionManager, ) +from tests.specify_cli.extensions._helpers import ( + LISTED_COMMAND_NAME_CASES, + write_listing_project_options, +) class TestExtensionInfoRendering: @@ -239,3 +243,50 @@ 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 + + write_listing_project_options(project_dir, init_options) + + 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 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