diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md
index c3391dbc75..80df9367a2 100644
--- a/extensions/EXTENSION-USER-GUIDE.md
+++ b/extensions/EXTENSION-USER-GUIDE.md
@@ -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
diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py
index b84b836545..25a65ee03f 100644
--- a/src/specify_cli/extensions/__init__.py
+++ b/src/specify_cli/extensions/__init__.py
@@ -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"(? 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.
@@ -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}"
@@ -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."""
@@ -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.
diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py
index c2d9bb439e..6b3d9f59cd 100644
--- a/tests/test_extension_skills.py
+++ b/tests/test_extension_skills.py
@@ -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: guide\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: ' 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