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

@@ -723,6 +723,7 @@ def _install_shared_infra(
script_type: str,
tracker: StepTracker | None = None,
force: bool = False,
invoke_separator: str = ".",
) -> bool:
"""Install shared infrastructure files into *project_path*.
@@ -730,12 +731,17 @@ def _install_shared_infra(
bundled core_pack or source checkout. Tracks all installed files
in ``speckit.manifest.json``.
Page templates are processed to resolve ``__SPECKIT_COMMAND_<NAME>__``
placeholders using *invoke_separator* (``"."`` for markdown agents,
``"-"`` for skills agents).
When *force* is ``True``, existing files are overwritten with the
latest bundled versions. When ``False`` (default), only missing
files are added and existing ones are skipped.
Returns ``True`` on success.
"""
from .integrations.base import IntegrationBase
from .integrations.manifest import IntegrationManifest
core = _locate_core_pack()
@@ -786,7 +792,11 @@ def _install_shared_infra(
if dst.exists() and not force:
skipped_files.append(str(dst.relative_to(project_path)))
else:
shutil.copy2(f, dst)
content = f.read_text(encoding="utf-8")
content = IntegrationBase.resolve_command_refs(
content, invoke_separator
)
dst.write_text(content, encoding="utf-8")
rel = dst.relative_to(project_path).as_posix()
manifest.record_existing(rel)
@@ -1295,7 +1305,7 @@ def init(
# Install shared infrastructure (scripts, templates)
tracker.start("shared-infra")
_install_shared_infra(project_path, selected_script, tracker=tracker, force=force)
_install_shared_infra(project_path, selected_script, tracker=tracker, force=force, invoke_separator=resolved_integration.effective_invoke_separator(integration_parsed_options))
tracker.complete("shared-infra", f"scripts ({selected_script}) + templates")
ensure_constitution_from_template(project_path, tracker=tracker)
@@ -2072,9 +2082,16 @@ def integration_install(
selected_script = _resolve_script_type(project_root, script)
# Build parsed options from --integration-options so the integration
# can determine its effective invoke separator before shared infra
# is installed.
parsed_options: dict[str, Any] | None = None
if integration_options:
parsed_options = _parse_integration_options(integration, integration_options)
# Ensure shared infrastructure is present (safe to run unconditionally;
# _install_shared_infra merges missing files without overwriting).
_install_shared_infra(project_root, selected_script)
_install_shared_infra(project_root, selected_script, invoke_separator=integration.effective_invoke_separator(parsed_options))
if os.name != "nt":
ensure_executable_scripts(project_root)
@@ -2082,11 +2099,6 @@ def integration_install(
integration.key, project_root, version=get_speckit_version()
)
# Build parsed options from --integration-options
parsed_options: dict[str, Any] | None = None
if integration_options:
parsed_options = _parse_integration_options(integration, integration_options)
try:
integration.setup(
project_root, manifest,
@@ -2356,9 +2368,16 @@ def integration_switch(
opts.pop("context_file", None)
save_init_options(project_root, opts)
# Build parsed options from --integration-options so the integration
# can determine its effective invoke separator before shared infra
# is installed.
parsed_options: dict[str, Any] | None = None
if integration_options:
parsed_options = _parse_integration_options(target_integration, integration_options)
# Ensure shared infrastructure is present (safe to run unconditionally;
# _install_shared_infra merges missing files without overwriting).
_install_shared_infra(project_root, selected_script)
_install_shared_infra(project_root, selected_script, invoke_separator=target_integration.effective_invoke_separator(parsed_options))
if os.name != "nt":
ensure_executable_scripts(project_root)
@@ -2368,10 +2387,6 @@ def integration_switch(
target_integration.key, project_root, version=get_speckit_version()
)
parsed_options: dict[str, Any] | None = None
if integration_options:
parsed_options = _parse_integration_options(target_integration, integration_options)
try:
target_integration.setup(
project_root, manifest,
@@ -2465,8 +2480,15 @@ def integration_upgrade(
selected_script = _resolve_script_type(project_root, script)
# Build parsed options from --integration-options so the integration
# can determine its effective invoke separator before shared infra
# is installed.
parsed_options: dict[str, Any] | None = None
if integration_options:
parsed_options = _parse_integration_options(integration, integration_options)
# Ensure shared infrastructure is up to date; --force overwrites existing files.
_install_shared_infra(project_root, selected_script, force=force)
_install_shared_infra(project_root, selected_script, force=force, invoke_separator=integration.effective_invoke_separator(parsed_options))
if os.name != "nt":
ensure_executable_scripts(project_root)
@@ -2474,10 +2496,6 @@ def integration_upgrade(
console.print(f"Upgrading integration: [cyan]{key}[/cyan]")
new_manifest = IntegrationManifest(key, project_root, version=get_speckit_version())
parsed_options: dict[str, Any] | None = None
if integration_options:
parsed_options = _parse_integration_options(integration, integration_options)
try:
integration.setup(
project_root,

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: