Skip to content

Commit 749ecdf

Browse files
author
root
committed
fix: normalize extension skill command references
1 parent 83883a2 commit 749ecdf

3 files changed

Lines changed: 87 additions & 25 deletions

File tree

extensions/EXTENSION-USER-GUIDE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,16 @@ Jira Integration (v1.0.0)
202202

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

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

207217
## Using Extensions

src/specify_cli/extensions/__init__.py

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1562,7 +1562,6 @@ def _register_extension_skills(
15621562
from .. import load_init_options
15631563
from ..agents import CommandRegistrar
15641564
from ..integrations import get_integration
1565-
from ..integrations.base import IntegrationBase
15661565

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

1578+
def _render_skill_command_invocation(command_name: str) -> str:
1579+
"""Render a command name with the active skill invocation style."""
1580+
1581+
if is_dollar_skills_agent(selected_ai, ai_skills_enabled):
1582+
return "$" + command_name.replace("speckit.", "speckit-").replace(
1583+
".", "-"
1584+
)
1585+
if is_slash_skills_agent(selected_ai, ai_skills_enabled):
1586+
return "/" + command_name.replace("speckit.", "speckit-").replace(
1587+
".", "-"
1588+
)
1589+
if integration is not None:
1590+
return integration.build_command_invocation(command_name)
1591+
1592+
separator = agent_config.get("invoke_separator", ".")
1593+
if not isinstance(separator, str) or not separator:
1594+
separator = "."
1595+
return "/" + command_name.replace(".", separator)
1596+
15791597
def _resolve_command_ref_tokens(body: str) -> str:
15801598
"""Resolve explicit command-ref tokens with the active skill style."""
15811599

15821600
def _replacement(match: re.Match[str]) -> str:
15831601
command_name = "speckit." + match.group(1).lower().replace("_", ".")
1584-
if is_dollar_skills_agent(selected_ai, ai_skills_enabled):
1585-
return "$" + command_name.replace("speckit.", "speckit-").replace(
1586-
".", "-"
1587-
)
1588-
if is_slash_skills_agent(selected_ai, ai_skills_enabled):
1589-
return "/" + command_name.replace("speckit.", "speckit-").replace(
1590-
".", "-"
1591-
)
1592-
if integration is not None:
1593-
return integration.build_command_invocation(command_name)
1594-
return IntegrationBase.resolve_command_refs(
1595-
match.group(0), agent_config.get("invoke_separator", ".")
1596-
)
1602+
return _render_skill_command_invocation(command_name)
15971603

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

1608+
def _normalize_literal_slash_command_refs(body: str) -> str:
1609+
"""Normalize literal /speckit.foo refs in generated skill bodies."""
1610+
1611+
def _replacement(match: re.Match[str]) -> str:
1612+
command_name = match.group("command")
1613+
if command_name.rsplit(".", 1)[-1] in {
1614+
"json",
1615+
"md",
1616+
"toml",
1617+
"txt",
1618+
"yaml",
1619+
"yml",
1620+
}:
1621+
return match.group(0)
1622+
return _render_skill_command_invocation(command_name)
1623+
1624+
return re.sub(
1625+
(
1626+
r"(?<![\w$:/-])"
1627+
r"/(?P<command>speckit\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+)"
1628+
r"(?![A-Za-z0-9_.-])"
1629+
),
1630+
_replacement,
1631+
body,
1632+
)
1633+
16021634
for cmd_info in manifest.commands:
16031635
cmd_name = cmd_info["name"]
16041636
cmd_file_rel = cmd_info["file"]
@@ -1678,6 +1710,7 @@ def _replacement(match: re.Match[str]) -> str:
16781710
selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id
16791711
)
16801712
body = _resolve_command_ref_tokens(body)
1713+
body = _normalize_literal_slash_command_refs(body)
16811714

16821715
original_desc = frontmatter.get("description", "")
16831716
description = original_desc or f"Extension command: {cmd_name}"

tests/test_extension_skills.py

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1166,12 +1166,24 @@ def test_skill_registration_resolves_command_ref_tokens(
11661166
assert "__SPECKIT_COMMAND_PLAN__" not in content
11671167
assert expected_invocation in content
11681168

1169-
def test_skill_registration_does_not_rewrite_literal_speckit_text(
1170-
self, project_dir, temp_dir
1169+
@pytest.mark.parametrize(
1170+
("ai", "expected_invocation"),
1171+
[
1172+
("claude", "/speckit-foo-bar"),
1173+
("copilot", "/speckit-foo-bar"),
1174+
("codex", "$speckit-foo-bar"),
1175+
("command-code", "$speckit-foo-bar"),
1176+
("kimi", "/skill:speckit-foo-bar"),
1177+
("zcode", "$speckit-foo-bar"),
1178+
("bob", "/speckit-foo-bar"),
1179+
],
1180+
)
1181+
def test_skill_registration_rewrites_literal_slash_command_refs(
1182+
self, project_dir, temp_dir, ai, expected_invocation
11711183
):
1172-
"""Auto-registered skills should leave literal speckit text untouched."""
1173-
_create_init_options(project_dir, ai="codex", ai_skills=True)
1174-
skills_dir = _create_skills_dir(project_dir, ai="codex")
1184+
"""Auto-registered skills should normalize literal slash-dot refs."""
1185+
_create_init_options(project_dir, ai=ai, ai_skills=True)
1186+
skills_dir = _create_skills_dir(project_dir, ai=ai)
11751187

11761188
ext_dir = temp_dir / "literal-ref-ext"
11771189
ext_dir.mkdir()
@@ -1202,20 +1214,27 @@ def test_skill_registration_does_not_rewrite_literal_speckit_text(
12021214
"---\n"
12031215
"description: Run command\n"
12041216
"---\n\n"
1205-
"Literal slash form: /speckit.foo.bar\n"
1206-
"Literal skill form: /speckit-plan\n"
1217+
"Literal slash form: /speckit.foo.bar --flag value\n"
1218+
"Native slash form: /speckit-foo-bar\n"
1219+
"Native dollar form: $speckit-foo-bar\n"
1220+
"Native skill form: /skill:speckit-foo-bar\n"
12071221
"Literal bare form: speckit.foo.bar\n"
1222+
"Path-like form: https://example.com/speckit.foo.bar\n"
1223+
"File-like form: /speckit.foo.bar.md\n"
12081224
)
12091225

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

12131229
content = (skills_dir / "speckit-literal-ref-ext-run" / "SKILL.md").read_text()
1214-
assert "/speckit.foo.bar" in content
1215-
assert "/speckit-plan" in content
1230+
assert f"Literal slash form: {expected_invocation} --flag value" in content
1231+
assert "Literal slash form: /speckit.foo.bar --flag value" not in content
1232+
assert "Native slash form: /speckit-foo-bar" in content
1233+
assert "Native dollar form: $speckit-foo-bar" in content
1234+
assert "Native skill form: /skill:speckit-foo-bar" in content
12161235
assert "speckit.foo.bar" in content
1217-
assert "/speckit-foo-bar" not in content
1218-
assert "$speckit-plan" not in content
1236+
assert "https://example.com/speckit.foo.bar" in content
1237+
assert "/speckit.foo.bar.md" in content
12191238

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

0 commit comments

Comments
 (0)