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
18 changes: 16 additions & 2 deletions src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1580,7 +1580,18 @@ 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 match.group(1) is not None:
# Uppercase form: reconstruct the id from underscores. This
# cannot represent a hyphen, so a hyphenated segment is
# silently split into extra segments.
command_name = (
"speckit." + match.group(1).lower().replace("_", ".")
)
else:
# Verbatim form: the canonical id is carried literally, so
# hyphenated command names (e.g. speckit.agent-context.update)
# survive intact.
command_name = match.group(2)
if is_dollar_skills_agent(selected_ai, ai_skills_enabled):
return "$" + command_name.replace("speckit.", "speckit-").replace(
".", "-"
Expand All @@ -1596,7 +1607,10 @@ def _replacement(match: re.Match[str]) -> str:
)

return re.sub(
r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", _replacement, body
r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__"
r"|__SPECKIT_COMMAND\(([^)]+)\)__",
Comment on lines +1610 to +1611
_replacement,
body,
)

for cmd_info in manifest.commands:
Expand Down
33 changes: 25 additions & 8 deletions src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -620,25 +620,42 @@ def install_scripts(
def resolve_command_refs(
content: str, separator: str = ".", prefix: str = "/"
) -> str:
"""Replace ``__SPECKIT_COMMAND_<NAME>__`` placeholders with invocations.
"""Replace ``__SPECKIT_COMMAND_*__`` placeholders with invocations.

Each placeholder encodes a command name in upper-case with
Two forms are supported:

**Uppercase form** — encodes a command name in upper-case with
underscores (e.g. ``__SPECKIT_COMMAND_PLAN__``,
``__SPECKIT_COMMAND_GIT_COMMIT__``). The replacement uses
``__SPECKIT_COMMAND_GIT_COMMIT__``). Each underscore is replaced by
*separator* to join the segments:

* ``separator="."`` → ``/speckit.plan``, ``/speckit.git.commit``
* ``separator="-"`` → ``/speckit-plan``, ``/speckit-git-commit``

**Verbatim form** — carries the canonical command ID verbatim inside
parentheses (e.g. ``__SPECKIT_COMMAND(speckit.agent-context.update)__``).
This form correctly handles command names that contain hyphens, which
the uppercase form cannot represent unambiguously. The canonical dots
are replaced by *separator*; hyphens within a segment are kept as-is
(they become *separator* when *separator* is ``"-"``):

* ``separator="."`` → ``/speckit.agent-context.update``
* ``separator="-"`` → ``/speckit-agent-context-update``

*prefix* defaults to ``"/"`` but may be ``"$"`` for agents whose
native skills invocation uses dollar-prefixed chat commands.
"""

def _replace(m: re.Match) -> str:
if m.group(1) is not None:
# Uppercase form: replace underscores with separator.
return prefix + "speckit" + separator + m.group(1).lower().replace("_", separator)
# Verbatim form: replace dots with separator; hyphens are kept.
return prefix + m.group(2).replace(".", separator)

return re.sub(
r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__",
lambda m: prefix
+ "speckit"
+ separator
+ m.group(1).lower().replace("_", separator),
r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__|__SPECKIT_COMMAND\(([^)]+)\)__",
_replace,
content,
)

Expand Down
39 changes: 39 additions & 0 deletions tests/integrations/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,45 @@ def test_placeholder_with_digits(self):
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "/speckit.v2.plan"

# -- Verbatim form tests --------------------------------------------------

def test_verbatim_dot_separator_with_hyphen(self):
"""Verbatim form correctly handles a hyphen in command name with dot separator."""
text = "__SPECKIT_COMMAND(speckit.agent-context.update)__"
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "/speckit.agent-context.update"

def test_verbatim_hyphen_separator_with_hyphen(self):
"""Verbatim form correctly handles a hyphen in command name with hyphen separator."""
text = "__SPECKIT_COMMAND(speckit.agent-context.update)__"
result = IntegrationBase.resolve_command_refs(text, "-")
assert result == "/speckit-agent-context-update"

def test_verbatim_dollar_prefix(self):
text = "__SPECKIT_COMMAND(speckit.agent-context.update)__"
result = IntegrationBase.resolve_command_refs(text, "-", "$")
assert result == "$speckit-agent-context-update"

def test_verbatim_simple_command_no_hyphens(self):
"""Verbatim form works for commands without hyphens too."""
text = "__SPECKIT_COMMAND(speckit.git.commit)__"
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "/speckit.git.commit"

def test_verbatim_and_uppercase_mixed(self):
"""Both forms can coexist in the same string."""
text = "__SPECKIT_COMMAND_PLAN__ and __SPECKIT_COMMAND(speckit.agent-context.update)__"
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "/speckit.plan and /speckit.agent-context.update"

def test_uppercase_form_unaffected_by_verbatim_support(self):
"""Regression: existing uppercase form still works correctly."""
text = "__SPECKIT_COMMAND_AGENT_CONTEXT_UPDATE__"
result_dot = IntegrationBase.resolve_command_refs(text, ".")
result_hyphen = IntegrationBase.resolve_command_refs(text, "-")
assert result_dot == "/speckit.agent.context.update"
assert result_hyphen == "/speckit-agent-context-update"


class TestResolvePythonInterpreter:
def test_returns_python_on_path(self, monkeypatch):
Expand Down
59 changes: 59 additions & 0 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3714,6 +3714,65 @@ def test_codex_skill_registration_uses_dollar_command_refs(
assert "$speckit-plan" in content
assert "/speckit-plan" not in content

def test_codex_skill_registration_resolves_verbatim_command_ref(
self, extension_dir, project_dir
):
"""Verbatim __SPECKIT_COMMAND(...)__ tokens resolve in skills mode too.

Regression for the resolver in ``_register_extension_skills`` (the sole
writer of extension SKILL.md content when the active integration is in
skills mode). It previously matched only the uppercase token form,
leaving the verbatim form (used for hyphenated command names) as a raw
literal in SKILL.md.
"""
(project_dir / ".specify" / "init-options.json").write_text(
'{"ai":"codex","ai_skills":true,"script":"sh"}', encoding="utf-8"
)
(project_dir / ".agents" / "skills").mkdir(parents=True)
command = extension_dir / "commands" / "hello.md"
command.write_text(
"---\ndescription: Test hello command\n---\n\n"
"Run __SPECKIT_COMMAND(speckit.agent-context.update)__.",
encoding="utf-8",
)

manager = ExtensionManager(project_dir)
manifest = ExtensionManifest(extension_dir / "extension.yml")
manager._register_extension_skills(manifest, extension_dir)

skill_file = (
project_dir / ".agents" / "skills" / "speckit-test-ext-hello" / "SKILL.md"
)
content = skill_file.read_text(encoding="utf-8")
assert "$speckit-agent-context-update" in content
assert "__SPECKIT_COMMAND(" not in content

def test_claude_skill_registration_resolves_verbatim_command_ref(
self, extension_dir, project_dir
):
"""Verbatim tokens with hyphens resolve for slash-skills agents."""
(project_dir / ".specify" / "init-options.json").write_text(
'{"ai":"claude","ai_skills":true,"script":"sh"}', encoding="utf-8"
)
(project_dir / ".claude" / "skills").mkdir(parents=True)
command = extension_dir / "commands" / "hello.md"
command.write_text(
"---\ndescription: Test hello command\n---\n\n"
"Run __SPECKIT_COMMAND(speckit.agent-context.update)__.",
encoding="utf-8",
)

manager = ExtensionManager(project_dir)
manifest = ExtensionManifest(extension_dir / "extension.yml")
manager._register_extension_skills(manifest, extension_dir)

skill_file = (
project_dir / ".claude" / "skills" / "speckit-test-ext-hello" / "SKILL.md"
)
content = skill_file.read_text(encoding="utf-8")
assert "/speckit-agent-context-update" in content
assert "__SPECKIT_COMMAND(" not in content

def test_codex_skill_registration_resolves_script_placeholders(self, project_dir, temp_dir):
"""Codex SKILL.md overrides should resolve script placeholders."""
import yaml
Expand Down