fix: resolve command references per integration type (dot vs hyphen) (#2354)

* fix: resolve command references per integration type (dot vs hyphen)

Replace hardcoded /speckit.<cmd> references in templates with
__SPECKIT_COMMAND_<NAME>__ placeholders that are resolved at
setup time based on the integration type:

- Markdown/TOML/YAML agents: separator='.' → /speckit.plan
- Skills agents: separator='-' → /speckit-plan

Changes:
- Add resolve_command_refs() static method to IntegrationBase
- Add invoke_separator class attribute (.  for base, - for skills)
- Wire into process_template() as step 8
- Update _install_shared_infra() to process page templates
- Replace /speckit.* in 5 command templates and 3 page templates
- Add unit tests for resolve_command_refs (positive + negative)
- Add integration tests verifying on-disk content for all agents
- Add end-to-end CLI tests for Claude (skills) and Copilot (markdown)

Fixes #2347

* review: use effective_invoke_separator() for Copilot skills mode

Address PR review feedback: instead of bleeding _skills_mode
knowledge into the CLI layer, add effective_invoke_separator()
method to IntegrationBase that accepts parsed_options.

CopilotIntegration overrides it to return "-" when skills
mode is requested. The CLI layer simply asks the integration
for its separator — no hasattr or _skills_mode coupling.

Also adds tests for the new method on both base and Copilot,
plus an end-to-end test for 'specify init --integration copilot
--integration-options --skills' verifying page templates get
hyphen refs.

* fix: build_command_invocation preserves full suffix for extension commands

Previously rsplit('.', 1)[-1] on 'speckit.git.commit' yielded
just 'commit', producing /speckit.commit instead of
/speckit.git.commit (or /speckit-git-commit for skills).

Fix: strip only the 'speckit.' prefix when present, then join
remaining segments with the appropriate separator.

Updated in IntegrationBase, SkillsIntegration, and
CopilotIntegration. Added tests for extension commands in
build_command_invocation across all three.

* fix: Copilot dispatch_command() preserves full extension command suffix

dispatch_command() had the same rsplit('.', 1)[-1] bug as
build_command_invocation() — speckit.git.commit would dispatch
as /speckit-commit instead of /speckit-git-commit in skills
mode, or --agent speckit.commit instead of speckit.git.commit
in default mode.
This commit is contained in:
Manfred Riem
2026-04-24 10:04:14 -05:00
committed by GitHub
parent 6413414907
commit 52c0a5f88f
21 changed files with 434 additions and 55 deletions

View File

@@ -84,6 +84,9 @@ class IntegrationBase(ABC):
context_file: str | None = None
"""Relative path to the agent context file (e.g. ``CLAUDE.md``)."""
invoke_separator: str = "."
"""Separator used in slash-command invocations (``"."`` → ``/speckit.plan``)."""
# -- Markers for managed context section ------------------------------
CONTEXT_MARKER_START = "<!-- SPECKIT START -->"
@@ -96,6 +99,18 @@ class IntegrationBase(ABC):
"""Return options this integration accepts. Default: none."""
return []
def effective_invoke_separator(
self, parsed_options: dict[str, Any] | None = None
) -> str:
"""Return the invoke separator for the given options.
Subclasses whose separator depends on runtime options (e.g.
Copilot in ``--skills`` mode) should override this method.
The default implementation ignores *parsed_options* and returns
the class-level ``invoke_separator``.
"""
return self.invoke_separator
def build_exec_args(
self,
prompt: str,
@@ -122,11 +137,12 @@ class IntegrationBase(ABC):
agents or ``"/speckit-specify my-feature"`` for skills agents.
*command_name* may be a full dotted name like
``"speckit.specify"`` or a bare stem like ``"specify"``.
``"speckit.specify"``, an extension command like
``"speckit.git.commit"``, or a bare stem like ``"specify"``.
"""
stem = command_name
if "." in stem:
stem = stem.rsplit(".", 1)[-1]
if stem.startswith("speckit."):
stem = stem[len("speckit."):]
invocation = f"/speckit.{stem}"
if args:
@@ -597,6 +613,24 @@ class IntegrationBase(ABC):
return True
@staticmethod
def resolve_command_refs(content: str, separator: str = ".") -> str:
"""Replace ``__SPECKIT_COMMAND_<NAME>__`` placeholders with invocations.
Each placeholder encodes a command name in upper-case with
underscores (e.g. ``__SPECKIT_COMMAND_PLAN__``,
``__SPECKIT_COMMAND_GIT_COMMIT__``). The replacement uses
*separator* to join the segments:
* ``separator="."`` → ``/speckit.plan``, ``/speckit.git.commit``
* ``separator="-"`` → ``/speckit-plan``, ``/speckit-git-commit``
"""
return re.sub(
r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__",
lambda m: "/speckit" + separator + m.group(1).lower().replace("_", separator),
content,
)
@staticmethod
def process_template(
content: str,
@@ -604,6 +638,7 @@ class IntegrationBase(ABC):
script_type: str,
arg_placeholder: str = "$ARGUMENTS",
context_file: str = "",
invoke_separator: str = ".",
) -> str:
"""Process a raw command template into agent-ready content.
@@ -615,6 +650,7 @@ class IntegrationBase(ABC):
5. Replace ``__AGENT__`` with *agent_name*
6. Replace ``__CONTEXT_FILE__`` with *context_file*
7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc.
8. Replace ``__SPECKIT_COMMAND_<NAME>__`` with invocation strings
"""
# 1. Extract script command from frontmatter
script_command = ""
@@ -684,6 +720,9 @@ class IntegrationBase(ABC):
content = CommandRegistrar.rewrite_project_relative_paths(content)
# 8. Replace __SPECKIT_COMMAND_<NAME>__ with invocation strings
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
return content
def setup(
@@ -1274,6 +1313,8 @@ class SkillsIntegration(IntegrationBase):
``speckit-<name>/SKILL.md`` file with skills-oriented frontmatter.
"""
invoke_separator = "-"
def build_exec_args(
self,
prompt: str,
@@ -1311,10 +1352,10 @@ class SkillsIntegration(IntegrationBase):
def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""Skills use ``/speckit-<stem>`` (hyphenated directory name)."""
stem = command_name
if "." in stem:
stem = stem.rsplit(".", 1)[-1]
if stem.startswith("speckit."):
stem = stem[len("speckit."):]
invocation = f"/speckit-{stem}"
invocation = "/speckit-" + stem.replace(".", "-")
if args:
invocation = f"{invocation} {args}"
return invocation
@@ -1395,6 +1436,7 @@ class SkillsIntegration(IntegrationBase):
processed_body = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=self.context_file or "",
invoke_separator=self.invoke_separator,
)
# Strip the processed frontmatter — we rebuild it for skills.
# Preserve leading whitespace in the body to match release ZIP

View File

@@ -103,6 +103,16 @@ class CopilotIntegration(IntegrationBase):
# Mutable flag set by setup() — indicates the active scaffolding mode.
_skills_mode: bool = False
def effective_invoke_separator(
self, parsed_options: dict[str, Any] | None = None
) -> str:
"""Return ``"-"`` when skills mode is requested, ``"."`` otherwise."""
if parsed_options and parsed_options.get("skills"):
return "-"
if self._skills_mode:
return "-"
return self.invoke_separator
@classmethod
def options(cls) -> list[IntegrationOption]:
return [
@@ -145,9 +155,9 @@ class CopilotIntegration(IntegrationBase):
"""
if self._skills_mode:
stem = command_name
if "." in stem:
stem = stem.rsplit(".", 1)[-1]
invocation = f"/speckit-{stem}"
if stem.startswith("speckit."):
stem = stem[len("speckit."):]
invocation = "/speckit-" + stem.replace(".", "-")
if args:
invocation = f"{invocation} {args}"
return invocation
@@ -175,8 +185,8 @@ class CopilotIntegration(IntegrationBase):
import subprocess
stem = command_name
if "." in stem:
stem = stem.rsplit(".", 1)[-1]
if stem.startswith("speckit."):
stem = stem[len("speckit."):]
# Detect skills mode from project layout when not set via setup()
skills_mode = self._skills_mode
@@ -189,7 +199,7 @@ class CopilotIntegration(IntegrationBase):
)
if skills_mode:
prompt = f"/speckit-{stem}"
prompt = "/speckit-" + stem.replace(".", "-")
if args:
prompt = f"{prompt} {args}"
else: