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
10 changes: 10 additions & 0 deletions extensions/EXTENSION-USER-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,16 @@ 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.

When one extension command needs to reference another Spec Kit command, prefer the
portable command token form, such as `__SPECKIT_COMMAND_PLAN__`, instead of
hard-coding a slash command. Spec Kit renders these tokens to the active
integration's command style. For skills-based integrations, generated extension
skills also normalize literal slash-dot command references such as
`/speckit.jira.specstoissues` to the active skill invocation form, for example
`$speckit-jira-specstoissues` for Codex or `/speckit-jira-specstoissues` for
slash-skills agents. Avoid using bare prose like `speckit.jira.specstoissues`
when you intend the agent to invoke a command.

---

## Using Extensions
Expand Down
61 changes: 47 additions & 14 deletions src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1562,7 +1562,6 @@ def _register_extension_skills(
from .. import load_init_options
from ..agents import CommandRegistrar
from ..integrations import get_integration
from ..integrations.base import IntegrationBase

written: List[str] = []
opts = load_init_options(self.project_root)
Expand All @@ -1576,29 +1575,62 @@ def _register_extension_skills(
integration = get_integration(selected_ai)
ai_skills_enabled = is_ai_skills_enabled(opts)

def _render_skill_command_invocation(command_name: str) -> str:
"""Render a command name with the active skill invocation style."""

if is_dollar_skills_agent(selected_ai, ai_skills_enabled):
return "$" + command_name.replace("speckit.", "speckit-").replace(
".", "-"
)
if is_slash_skills_agent(selected_ai, ai_skills_enabled):
return "/" + command_name.replace("speckit.", "speckit-").replace(
".", "-"
)
if integration is not None:
return integration.build_command_invocation(command_name)

separator = agent_config.get("invoke_separator", ".")
if not isinstance(separator, str) or not separator:
separator = "."
return "/" + command_name.replace(".", separator)

def _resolve_command_ref_tokens(body: str) -> str:
"""Resolve explicit command-ref tokens with the active skill style."""

def _replacement(match: re.Match[str]) -> str:
command_name = "speckit." + match.group(1).lower().replace("_", ".")
if is_dollar_skills_agent(selected_ai, ai_skills_enabled):
return "$" + command_name.replace("speckit.", "speckit-").replace(
".", "-"
)
if is_slash_skills_agent(selected_ai, ai_skills_enabled):
return "/" + command_name.replace("speckit.", "speckit-").replace(
".", "-"
)
if integration is not None:
return integration.build_command_invocation(command_name)
return IntegrationBase.resolve_command_refs(
match.group(0), agent_config.get("invoke_separator", ".")
)
return _render_skill_command_invocation(command_name)

return re.sub(
r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", _replacement, body
)

def _normalize_literal_slash_command_refs(body: str) -> str:
"""Normalize literal /speckit.foo refs in generated skill bodies."""

def _replacement(match: re.Match[str]) -> str:
command_name = match.group("command")
if command_name.rsplit(".", 1)[-1] in {
"json",
"md",
"toml",
"txt",
"yaml",
"yml",
}:
Comment on lines +1613 to +1620
return match.group(0)
return _render_skill_command_invocation(command_name)

return re.sub(
(
r"(?<![\w$:/-])"
r"/(?P<command>speckit\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+)"
r"(?![A-Za-z0-9_.-])"
),
_replacement,
body,
)

for cmd_info in manifest.commands:
cmd_name = cmd_info["name"]
cmd_file_rel = cmd_info["file"]
Expand Down Expand Up @@ -1678,6 +1710,7 @@ def _replacement(match: re.Match[str]) -> str:
selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id
)
body = _resolve_command_ref_tokens(body)
body = _normalize_literal_slash_command_refs(body)

original_desc = frontmatter.get("description", "")
description = original_desc or f"Extension command: {cmd_name}"
Expand Down
41 changes: 30 additions & 11 deletions tests/test_extension_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -1166,12 +1166,24 @@ def test_skill_registration_resolves_command_ref_tokens(
assert "__SPECKIT_COMMAND_PLAN__" not in content
assert expected_invocation in content

def test_skill_registration_does_not_rewrite_literal_speckit_text(
self, project_dir, temp_dir
@pytest.mark.parametrize(
("ai", "expected_invocation"),
[
("claude", "/speckit-foo-bar"),
("copilot", "/speckit-foo-bar"),
("codex", "$speckit-foo-bar"),
("command-code", "$speckit-foo-bar"),
("kimi", "/skill:speckit-foo-bar"),
("zcode", "$speckit-foo-bar"),
("bob", "/speckit-foo-bar"),
],
)
def test_skill_registration_rewrites_literal_slash_command_refs(
self, project_dir, temp_dir, ai, expected_invocation
):
"""Auto-registered skills should leave literal speckit text untouched."""
_create_init_options(project_dir, ai="codex", ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai="codex")
"""Auto-registered skills should normalize literal slash-dot refs."""
_create_init_options(project_dir, ai=ai, ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai=ai)

ext_dir = temp_dir / "literal-ref-ext"
ext_dir.mkdir()
Expand Down Expand Up @@ -1202,20 +1214,27 @@ def test_skill_registration_does_not_rewrite_literal_speckit_text(
"---\n"
"description: Run command\n"
"---\n\n"
"Literal slash form: /speckit.foo.bar\n"
"Literal skill form: /speckit-plan\n"
"Literal slash form: /speckit.foo.bar --flag value\n"
"Native slash form: /speckit-foo-bar\n"
"Native dollar form: $speckit-foo-bar\n"
"Native skill form: /skill:speckit-foo-bar\n"
"Literal bare form: speckit.foo.bar\n"
"Path-like form: https://example.com/speckit.foo.bar\n"
"File-like form: /speckit.foo.bar.md\n"
)

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

content = (skills_dir / "speckit-literal-ref-ext-run" / "SKILL.md").read_text()
assert "/speckit.foo.bar" in content
assert "/speckit-plan" in content
assert f"Literal slash form: {expected_invocation} --flag value" in content
assert "Literal slash form: /speckit.foo.bar --flag value" not in content
assert "Native slash form: /speckit-foo-bar" in content
assert "Native dollar form: $speckit-foo-bar" in content
assert "Native skill form: /skill:speckit-foo-bar" in content
assert "speckit.foo.bar" in content
assert "/speckit-foo-bar" not in content
assert "$speckit-plan" not in content
assert "https://example.com/speckit.foo.bar" in content
assert "/speckit.foo.bar.md" in content

def test_missing_command_file_skipped(self, skills_project, temp_dir):
"""Commands with missing source files should be skipped gracefully."""
Expand Down