Skip to content
Open
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 @@ -202,6 +202,8 @@ Jira Integration (v1.0.0)

When an extension is removed, its corresponding skills are also cleaned up automatically. Pre-existing skills that were manually customized are never overwritten.

Extension command bodies can keep portable slash-dot references such as `/speckit.jira.sync-status`. During skill registration, spec-kit rewrites standalone command references to the active agent's invocation style—for example, `$speckit-jira-sync-status` for Codex, `/skill:speckit-jira-sync-status` for Kimi, or `/speckit-jira-sync-status` for slash-based skills integrations. URL and path-like references are left unchanged.

---

## Using Extensions
Expand Down
99 changes: 73 additions & 26 deletions src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@
}
)
EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$")
_SPECKIT_SLASH_REF_PATTERN = re.compile(
r"(?<!\]\()(?<![.\w:/\\])"
r"/(speckit\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*)\b"
r"(?![\w/\\?#-]|\.[A-Za-z0-9_-])"
)
_MARKDOWN_LINK_DESTINATION_PREFIX_PATTERN = re.compile(r"\]\(\s*$")
_HTML_LINK_ATTRIBUTE_PREFIX_PATTERN = re.compile(
r"\b(?:href|src)\s*=\s*['\"]\s*$", re.IGNORECASE
)

VALID_EFFECTS = frozenset({"read-only", "read-write"})

Expand All @@ -57,6 +66,59 @@
REINSTALL_COMMAND = "uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git"


def _skill_name_from_command(command: Any) -> str:
"""Map a command id like ``speckit.plan`` to its installed skill name."""
if not isinstance(command, str):
return ""
command_id = command.strip()
if not command_id.startswith("speckit."):
return ""
return f"speckit-{command_id[len('speckit.') :].replace('.', '-')}"


def _render_agent_command_invocation(
command: Any, selected_ai: Any, ai_skills_enabled: bool
) -> str:
"""Render a command using the active agent's invocation syntax."""
if not isinstance(command, str):
return ""

command_id = command.strip()
if not command_id:
return ""

skill_name = _skill_name_from_command(command_id)
if is_dollar_skills_agent(selected_ai, ai_skills_enabled) and skill_name:
return f"${skill_name}"
if selected_ai == "kimi" and skill_name:
return f"/skill:{skill_name}"
if selected_ai == "cline":
from ..integrations.cline import format_cline_command_name

return f"/{format_cline_command_name(command_id)}"
if is_slash_skills_agent(selected_ai, ai_skills_enabled) and skill_name:
return f"/{skill_name}"
return f"/{command_id}"


def _rewrite_skill_body_command_refs(
body: str, selected_ai: str, ai_skills_enabled: bool
) -> str:
"""Rewrite slash-dot command references for an agent skill body."""
def replace(match: re.Match[str]) -> str:
line_start = body.rfind("\n", 0, match.start()) + 1
line_prefix = body[line_start : match.start()]
if _MARKDOWN_LINK_DESTINATION_PREFIX_PATTERN.search(
line_prefix
) or _HTML_LINK_ATTRIBUTE_PREFIX_PATTERN.search(line_prefix):
return match.group(0)
return _render_agent_command_invocation(
match.group(1), selected_ai, ai_skills_enabled
)

return _SPECKIT_SLASH_REF_PATTERN.sub(replace, body)


def _load_core_command_names() -> frozenset[str]:
"""Discover bundled core command names from the packaged templates.

Expand Down Expand Up @@ -1081,6 +1143,13 @@ def _register_extension_skills(
body = registrar.resolve_skill_placeholders(
selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id
)
# Extension authors commonly reference sibling commands using the
# portable slash-dot spelling (for example,
# ``/speckit.memory.prepare``). Rewrite those references to the
# active skills integration's native invocation form.
body = _rewrite_skill_body_command_refs(
body, selected_ai, is_ai_skills_enabled(opts)
)

original_desc = frontmatter.get("description", "")
description = original_desc or f"Extension command: {cmd_name}"
Expand Down Expand Up @@ -2898,12 +2967,7 @@ def _load_init_options(self) -> Dict[str, Any]:
@staticmethod
def _skill_name_from_command(command: Any) -> str:
"""Map a command id like speckit.plan to speckit-plan skill name."""
if not isinstance(command, str):
return ""
command_id = command.strip()
if not command_id.startswith("speckit."):
return ""
return f"speckit-{command_id[len('speckit.') :].replace('.', '-')}"
return _skill_name_from_command(command)

def _render_hook_invocation(self, command: Any) -> str:
"""Render an agent-specific invocation string for a hook command."""
Expand All @@ -2918,26 +2982,9 @@ def _render_hook_invocation(self, command: Any) -> str:
selected_ai = init_options.get("ai")
ai_skills_enabled = is_ai_skills_enabled(init_options)

dollar_skill_mode = is_dollar_skills_agent(selected_ai, ai_skills_enabled)
kimi_skill_mode = selected_ai == "kimi"
cline_mode = selected_ai == "cline"

skill_name = self._skill_name_from_command(command_id)
if dollar_skill_mode and skill_name:
return f"${skill_name}"
if kimi_skill_mode and skill_name:
return f"/skill:{skill_name}"
if cline_mode:
from ..integrations.cline import format_cline_command_name

return f"/{format_cline_command_name(command_id)}"

use_slash = is_slash_skills_agent(selected_ai, ai_skills_enabled)

if skill_name and use_slash:
return f"/{skill_name}"

return f"/{command_id}"
return _render_agent_command_invocation(
command_id, selected_ai, ai_skills_enabled
)

def get_project_config(self) -> Dict[str, Any]:
"""Load project-level extension configuration.
Expand Down
49 changes: 49 additions & 0 deletions tests/test_extension_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,55 @@ def test_returns_none_for_non_dict_init_options(self, project_dir):
class TestExtensionSkillRegistration:
"""Test _register_extension_skills() on ExtensionManager."""

@pytest.mark.parametrize(
("ai", "expected_invocation"),
[
("codex", "$speckit-test-ext-world"),
("kimi", "/skill:speckit-test-ext-world"),
("claude", "/speckit-test-ext-world"),
],
)
def test_dotted_command_refs_use_skill_invocation(
self, project_dir, extension_dir, ai, expected_invocation
):
"""Extension cross-command refs should match each skills integration."""
_create_init_options(project_dir, ai=ai, ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai=ai)
hello_command = extension_dir / "commands" / "hello.md"
hello_command.write_text(
hello_command.read_text(encoding="utf-8")
+ (
"\nContinue with /speckit.test-ext.world.\n"
"Documentation: https://speckit.test-ext.world/docs\n"
"Local path: ./speckit.test-ext.world/docs\n"
"Parent path: ../speckit.test-ext.world/docs\n"
"Root path: /speckit.test-ext.world/docs\n"
"Markdown link: [guide](/speckit.test-ext.world.md)\n"
"Spaced link: [guide]( /speckit.test-ext.world.md )\n"
'HTML link: <a href="/speckit.test-ext.world.md">guide</a>\n'
"Query path: /speckit.test-ext.world?raw=1\n"
),
encoding="utf-8",
)

manager = ExtensionManager(project_dir)
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)

content = (
skills_dir / "speckit-test-ext-hello" / "SKILL.md"
).read_text(encoding="utf-8")
assert f"Continue with {expected_invocation}." in content
assert "Documentation: https://speckit.test-ext.world/docs" in content
assert "Local path: ./speckit.test-ext.world/docs" in content
assert "Parent path: ../speckit.test-ext.world/docs" in content
assert "Root path: /speckit.test-ext.world/docs" in content
assert "Markdown link: [guide](/speckit.test-ext.world.md)" in content
assert "Spaced link: [guide]( /speckit.test-ext.world.md )" in content
assert 'HTML link: <a href="/speckit.test-ext.world.md">' in content
assert "Query path: /speckit.test-ext.world?raw=1" in content

def test_skills_created_when_ai_skills_active(self, skills_project, extension_dir):
"""Skills should be created when ai_skills is enabled."""
project_dir, skills_dir = skills_project
Expand Down