Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions extensions/EXTENSION-USER-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions src/specify_cli/extensions/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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-<ext>-<cmd>``
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,
Expand Down
15 changes: 1 addition & 14 deletions src/specify_cli/extensions/command_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 6 additions & 21 deletions src/specify_cli/extensions/command_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
18 changes: 18 additions & 0 deletions tests/integrations/test_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
59 changes: 59 additions & 0 deletions tests/specify_cli/extensions/_helpers.py
Original file line number Diff line number Diff line change
@@ -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"),
# <commands-dir>/speckit-test-ext-hello/SKILL.md
pytest.param({"ai": "generic", "ai_skills": True}, _HYPHENATED, _DOTTED, id="generic-skills"),
# <commands-dir>/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."""
Expand Down
28 changes: 28 additions & 0 deletions tests/specify_cli/extensions/test_command_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down Expand Up @@ -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
51 changes: 51 additions & 0 deletions tests/specify_cli/extensions/test_command_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
49 changes: 49 additions & 0 deletions tests/specify_cli/integrations/test_installed_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading