Skip to content

Commit 4fed84a

Browse files
WOLIKIMCHENGroot
andauthored
fix(extensions): resolve __SPECKIT_COMMAND tokens in auto-registered skills (#3544)
* fix(extensions): resolve command ref tokens in extension skills * fix(extensions): render skill command refs by invocation style Resolve extension skill command-reference tokens with the active skill invocation style so Codex and ZCode use $speckit-* while slash-style agents keep their native forms. Preserve literal command-looking text. * fix(extensions): resolve slash skill command refs from init options --------- Co-authored-by: root <kinsonnee@gmail.com>
1 parent 459f483 commit 4fed84a

2 files changed

Lines changed: 133 additions & 0 deletions

File tree

src/specify_cli/extensions/__init__.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,6 +1004,7 @@ def _register_extension_skills(
10041004
from .. import load_init_options
10051005
from ..agents import CommandRegistrar
10061006
from ..integrations import get_integration
1007+
from ..integrations.base import IntegrationBase
10071008

10081009
written: List[str] = []
10091010
opts = load_init_options(self.project_root)
@@ -1015,6 +1016,30 @@ def _register_extension_skills(
10151016
registrar = CommandRegistrar()
10161017
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})
10171018
integration = get_integration(selected_ai)
1019+
ai_skills_enabled = is_ai_skills_enabled(opts)
1020+
1021+
def _resolve_command_ref_tokens(body: str) -> str:
1022+
"""Resolve explicit command-ref tokens with the active skill style."""
1023+
1024+
def _replacement(match: re.Match[str]) -> str:
1025+
command_name = "speckit." + match.group(1).lower().replace("_", ".")
1026+
if is_dollar_skills_agent(selected_ai, ai_skills_enabled):
1027+
return "$" + command_name.replace("speckit.", "speckit-").replace(
1028+
".", "-"
1029+
)
1030+
if is_slash_skills_agent(selected_ai, ai_skills_enabled):
1031+
return "/" + command_name.replace("speckit.", "speckit-").replace(
1032+
".", "-"
1033+
)
1034+
if integration is not None:
1035+
return integration.build_command_invocation(command_name)
1036+
return IntegrationBase.resolve_command_refs(
1037+
match.group(0), agent_config.get("invoke_separator", ".")
1038+
)
1039+
1040+
return re.sub(
1041+
r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", _replacement, body
1042+
)
10181043

10191044
for cmd_info in manifest.commands:
10201045
cmd_name = cmd_info["name"]
@@ -1086,6 +1111,7 @@ def _register_extension_skills(
10861111
body = registrar.resolve_skill_placeholders(
10871112
selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id
10881113
)
1114+
body = _resolve_command_ref_tokens(body)
10891115

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

tests/test_extension_skills.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -970,6 +970,113 @@ def test_skill_registration_rewrites_extension_subdir_paths(self, project_dir, t
970970
assert "Read agents/control" not in content
971971
assert "and knowledge-base/" not in content
972972

973+
@pytest.mark.parametrize(
974+
("ai", "expected_invocation"),
975+
[
976+
("claude", "/speckit-plan"),
977+
("copilot", "/speckit-plan"),
978+
("codex", "$speckit-plan"),
979+
("kimi", "/skill:speckit-plan"),
980+
("zcode", "$speckit-plan"),
981+
],
982+
)
983+
def test_skill_registration_resolves_command_ref_tokens(
984+
self, project_dir, temp_dir, ai, expected_invocation
985+
):
986+
"""Auto-registered skills should resolve explicit command ref tokens."""
987+
_create_init_options(project_dir, ai=ai, ai_skills=True)
988+
skills_dir = _create_skills_dir(project_dir, ai=ai)
989+
990+
ext_dir = temp_dir / "command-ref-ext"
991+
ext_dir.mkdir()
992+
manifest_data = {
993+
"schema_version": "1.0",
994+
"extension": {
995+
"id": "command-ref-ext",
996+
"name": "Command Ref Extension",
997+
"version": "1.0.0",
998+
"description": "Test",
999+
},
1000+
"requires": {"speckit_version": ">=0.1.0"},
1001+
"provides": {
1002+
"commands": [
1003+
{
1004+
"name": "speckit.command-ref-ext.run",
1005+
"file": "commands/run.md",
1006+
"description": "Run command",
1007+
}
1008+
]
1009+
},
1010+
}
1011+
with open(ext_dir / "extension.yml", "w") as f:
1012+
yaml.safe_dump(manifest_data, f)
1013+
1014+
(ext_dir / "commands").mkdir()
1015+
(ext_dir / "commands" / "run.md").write_text(
1016+
"---\n"
1017+
"description: Run command\n"
1018+
"---\n\n"
1019+
"Use __SPECKIT_COMMAND_PLAN__ before proceeding.\n"
1020+
)
1021+
1022+
manager = ExtensionManager(project_dir)
1023+
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
1024+
1025+
content = (skills_dir / "speckit-command-ref-ext-run" / "SKILL.md").read_text()
1026+
assert "__SPECKIT_COMMAND_PLAN__" not in content
1027+
assert expected_invocation in content
1028+
1029+
def test_skill_registration_does_not_rewrite_literal_speckit_text(
1030+
self, project_dir, temp_dir
1031+
):
1032+
"""Auto-registered skills should leave literal speckit text untouched."""
1033+
_create_init_options(project_dir, ai="codex", ai_skills=True)
1034+
skills_dir = _create_skills_dir(project_dir, ai="codex")
1035+
1036+
ext_dir = temp_dir / "literal-ref-ext"
1037+
ext_dir.mkdir()
1038+
manifest_data = {
1039+
"schema_version": "1.0",
1040+
"extension": {
1041+
"id": "literal-ref-ext",
1042+
"name": "Literal Ref Extension",
1043+
"version": "1.0.0",
1044+
"description": "Test",
1045+
},
1046+
"requires": {"speckit_version": ">=0.1.0"},
1047+
"provides": {
1048+
"commands": [
1049+
{
1050+
"name": "speckit.literal-ref-ext.run",
1051+
"file": "commands/run.md",
1052+
"description": "Run command",
1053+
}
1054+
]
1055+
},
1056+
}
1057+
with open(ext_dir / "extension.yml", "w") as f:
1058+
yaml.safe_dump(manifest_data, f)
1059+
1060+
(ext_dir / "commands").mkdir()
1061+
(ext_dir / "commands" / "run.md").write_text(
1062+
"---\n"
1063+
"description: Run command\n"
1064+
"---\n\n"
1065+
"Literal slash form: /speckit.foo.bar\n"
1066+
"Literal skill form: /speckit-plan\n"
1067+
"Literal bare form: speckit.foo.bar\n"
1068+
)
1069+
1070+
manager = ExtensionManager(project_dir)
1071+
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
1072+
1073+
content = (skills_dir / "speckit-literal-ref-ext-run" / "SKILL.md").read_text()
1074+
assert "/speckit.foo.bar" in content
1075+
assert "/speckit-plan" in content
1076+
assert "speckit.foo.bar" in content
1077+
assert "/speckit-foo-bar" not in content
1078+
assert "$speckit-plan" not in content
1079+
9731080
def test_missing_command_file_skipped(self, skills_project, temp_dir):
9741081
"""Commands with missing source files should be skipped gracefully."""
9751082
project_dir, skills_dir = skills_project

0 commit comments

Comments
 (0)