Skip to content

Commit 52c0a5f

Browse files
authored
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.
1 parent 6413414 commit 52c0a5f

21 files changed

Lines changed: 434 additions & 55 deletions

src/specify_cli/__init__.py

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -723,19 +723,25 @@ def _install_shared_infra(
723723
script_type: str,
724724
tracker: StepTracker | None = None,
725725
force: bool = False,
726+
invoke_separator: str = ".",
726727
) -> bool:
727728
"""Install shared infrastructure files into *project_path*.
728729
729730
Copies ``.specify/scripts/`` and ``.specify/templates/`` from the
730731
bundled core_pack or source checkout. Tracks all installed files
731732
in ``speckit.manifest.json``.
732733
734+
Page templates are processed to resolve ``__SPECKIT_COMMAND_<NAME>__``
735+
placeholders using *invoke_separator* (``"."`` for markdown agents,
736+
``"-"`` for skills agents).
737+
733738
When *force* is ``True``, existing files are overwritten with the
734739
latest bundled versions. When ``False`` (default), only missing
735740
files are added and existing ones are skipped.
736741
737742
Returns ``True`` on success.
738743
"""
744+
from .integrations.base import IntegrationBase
739745
from .integrations.manifest import IntegrationManifest
740746

741747
core = _locate_core_pack()
@@ -786,7 +792,11 @@ def _install_shared_infra(
786792
if dst.exists() and not force:
787793
skipped_files.append(str(dst.relative_to(project_path)))
788794
else:
789-
shutil.copy2(f, dst)
795+
content = f.read_text(encoding="utf-8")
796+
content = IntegrationBase.resolve_command_refs(
797+
content, invoke_separator
798+
)
799+
dst.write_text(content, encoding="utf-8")
790800
rel = dst.relative_to(project_path).as_posix()
791801
manifest.record_existing(rel)
792802

@@ -1295,7 +1305,7 @@ def init(
12951305

12961306
# Install shared infrastructure (scripts, templates)
12971307
tracker.start("shared-infra")
1298-
_install_shared_infra(project_path, selected_script, tracker=tracker, force=force)
1308+
_install_shared_infra(project_path, selected_script, tracker=tracker, force=force, invoke_separator=resolved_integration.effective_invoke_separator(integration_parsed_options))
12991309
tracker.complete("shared-infra", f"scripts ({selected_script}) + templates")
13001310

13011311
ensure_constitution_from_template(project_path, tracker=tracker)
@@ -2072,21 +2082,23 @@ def integration_install(
20722082

20732083
selected_script = _resolve_script_type(project_root, script)
20742084

2085+
# Build parsed options from --integration-options so the integration
2086+
# can determine its effective invoke separator before shared infra
2087+
# is installed.
2088+
parsed_options: dict[str, Any] | None = None
2089+
if integration_options:
2090+
parsed_options = _parse_integration_options(integration, integration_options)
2091+
20752092
# Ensure shared infrastructure is present (safe to run unconditionally;
20762093
# _install_shared_infra merges missing files without overwriting).
2077-
_install_shared_infra(project_root, selected_script)
2094+
_install_shared_infra(project_root, selected_script, invoke_separator=integration.effective_invoke_separator(parsed_options))
20782095
if os.name != "nt":
20792096
ensure_executable_scripts(project_root)
20802097

20812098
manifest = IntegrationManifest(
20822099
integration.key, project_root, version=get_speckit_version()
20832100
)
20842101

2085-
# Build parsed options from --integration-options
2086-
parsed_options: dict[str, Any] | None = None
2087-
if integration_options:
2088-
parsed_options = _parse_integration_options(integration, integration_options)
2089-
20902102
try:
20912103
integration.setup(
20922104
project_root, manifest,
@@ -2356,9 +2368,16 @@ def integration_switch(
23562368
opts.pop("context_file", None)
23572369
save_init_options(project_root, opts)
23582370

2371+
# Build parsed options from --integration-options so the integration
2372+
# can determine its effective invoke separator before shared infra
2373+
# is installed.
2374+
parsed_options: dict[str, Any] | None = None
2375+
if integration_options:
2376+
parsed_options = _parse_integration_options(target_integration, integration_options)
2377+
23592378
# Ensure shared infrastructure is present (safe to run unconditionally;
23602379
# _install_shared_infra merges missing files without overwriting).
2361-
_install_shared_infra(project_root, selected_script)
2380+
_install_shared_infra(project_root, selected_script, invoke_separator=target_integration.effective_invoke_separator(parsed_options))
23622381
if os.name != "nt":
23632382
ensure_executable_scripts(project_root)
23642383

@@ -2368,10 +2387,6 @@ def integration_switch(
23682387
target_integration.key, project_root, version=get_speckit_version()
23692388
)
23702389

2371-
parsed_options: dict[str, Any] | None = None
2372-
if integration_options:
2373-
parsed_options = _parse_integration_options(target_integration, integration_options)
2374-
23752390
try:
23762391
target_integration.setup(
23772392
project_root, manifest,
@@ -2465,19 +2480,22 @@ def integration_upgrade(
24652480

24662481
selected_script = _resolve_script_type(project_root, script)
24672482

2483+
# Build parsed options from --integration-options so the integration
2484+
# can determine its effective invoke separator before shared infra
2485+
# is installed.
2486+
parsed_options: dict[str, Any] | None = None
2487+
if integration_options:
2488+
parsed_options = _parse_integration_options(integration, integration_options)
2489+
24682490
# Ensure shared infrastructure is up to date; --force overwrites existing files.
2469-
_install_shared_infra(project_root, selected_script, force=force)
2491+
_install_shared_infra(project_root, selected_script, force=force, invoke_separator=integration.effective_invoke_separator(parsed_options))
24702492
if os.name != "nt":
24712493
ensure_executable_scripts(project_root)
24722494

24732495
# Phase 1: Install new files (overwrites existing; old-only files remain)
24742496
console.print(f"Upgrading integration: [cyan]{key}[/cyan]")
24752497
new_manifest = IntegrationManifest(key, project_root, version=get_speckit_version())
24762498

2477-
parsed_options: dict[str, Any] | None = None
2478-
if integration_options:
2479-
parsed_options = _parse_integration_options(integration, integration_options)
2480-
24812499
try:
24822500
integration.setup(
24832501
project_root,

src/specify_cli/integrations/base.py

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ class IntegrationBase(ABC):
8484
context_file: str | None = None
8585
"""Relative path to the agent context file (e.g. ``CLAUDE.md``)."""
8686

87+
invoke_separator: str = "."
88+
"""Separator used in slash-command invocations (``"."`` → ``/speckit.plan``)."""
89+
8790
# -- Markers for managed context section ------------------------------
8891

8992
CONTEXT_MARKER_START = "<!-- SPECKIT START -->"
@@ -96,6 +99,18 @@ def options(cls) -> list[IntegrationOption]:
9699
"""Return options this integration accepts. Default: none."""
97100
return []
98101

102+
def effective_invoke_separator(
103+
self, parsed_options: dict[str, Any] | None = None
104+
) -> str:
105+
"""Return the invoke separator for the given options.
106+
107+
Subclasses whose separator depends on runtime options (e.g.
108+
Copilot in ``--skills`` mode) should override this method.
109+
The default implementation ignores *parsed_options* and returns
110+
the class-level ``invoke_separator``.
111+
"""
112+
return self.invoke_separator
113+
99114
def build_exec_args(
100115
self,
101116
prompt: str,
@@ -122,11 +137,12 @@ def build_command_invocation(self, command_name: str, args: str = "") -> str:
122137
agents or ``"/speckit-specify my-feature"`` for skills agents.
123138
124139
*command_name* may be a full dotted name like
125-
``"speckit.specify"`` or a bare stem like ``"specify"``.
140+
``"speckit.specify"``, an extension command like
141+
``"speckit.git.commit"``, or a bare stem like ``"specify"``.
126142
"""
127143
stem = command_name
128-
if "." in stem:
129-
stem = stem.rsplit(".", 1)[-1]
144+
if stem.startswith("speckit."):
145+
stem = stem[len("speckit."):]
130146

131147
invocation = f"/speckit.{stem}"
132148
if args:
@@ -597,13 +613,32 @@ def remove_context_section(self, project_root: Path) -> bool:
597613

598614
return True
599615

616+
@staticmethod
617+
def resolve_command_refs(content: str, separator: str = ".") -> str:
618+
"""Replace ``__SPECKIT_COMMAND_<NAME>__`` placeholders with invocations.
619+
620+
Each placeholder encodes a command name in upper-case with
621+
underscores (e.g. ``__SPECKIT_COMMAND_PLAN__``,
622+
``__SPECKIT_COMMAND_GIT_COMMIT__``). The replacement uses
623+
*separator* to join the segments:
624+
625+
* ``separator="."`` → ``/speckit.plan``, ``/speckit.git.commit``
626+
* ``separator="-"`` → ``/speckit-plan``, ``/speckit-git-commit``
627+
"""
628+
return re.sub(
629+
r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__",
630+
lambda m: "/speckit" + separator + m.group(1).lower().replace("_", separator),
631+
content,
632+
)
633+
600634
@staticmethod
601635
def process_template(
602636
content: str,
603637
agent_name: str,
604638
script_type: str,
605639
arg_placeholder: str = "$ARGUMENTS",
606640
context_file: str = "",
641+
invoke_separator: str = ".",
607642
) -> str:
608643
"""Process a raw command template into agent-ready content.
609644
@@ -615,6 +650,7 @@ def process_template(
615650
5. Replace ``__AGENT__`` with *agent_name*
616651
6. Replace ``__CONTEXT_FILE__`` with *context_file*
617652
7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc.
653+
8. Replace ``__SPECKIT_COMMAND_<NAME>__`` with invocation strings
618654
"""
619655
# 1. Extract script command from frontmatter
620656
script_command = ""
@@ -684,6 +720,9 @@ def process_template(
684720

685721
content = CommandRegistrar.rewrite_project_relative_paths(content)
686722

723+
# 8. Replace __SPECKIT_COMMAND_<NAME>__ with invocation strings
724+
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
725+
687726
return content
688727

689728
def setup(
@@ -1274,6 +1313,8 @@ class SkillsIntegration(IntegrationBase):
12741313
``speckit-<name>/SKILL.md`` file with skills-oriented frontmatter.
12751314
"""
12761315

1316+
invoke_separator = "-"
1317+
12771318
def build_exec_args(
12781319
self,
12791320
prompt: str,
@@ -1311,10 +1352,10 @@ def skills_dest(self, project_root: Path) -> Path:
13111352
def build_command_invocation(self, command_name: str, args: str = "") -> str:
13121353
"""Skills use ``/speckit-<stem>`` (hyphenated directory name)."""
13131354
stem = command_name
1314-
if "." in stem:
1315-
stem = stem.rsplit(".", 1)[-1]
1355+
if stem.startswith("speckit."):
1356+
stem = stem[len("speckit."):]
13161357

1317-
invocation = f"/speckit-{stem}"
1358+
invocation = "/speckit-" + stem.replace(".", "-")
13181359
if args:
13191360
invocation = f"{invocation} {args}"
13201361
return invocation
@@ -1395,6 +1436,7 @@ def setup(
13951436
processed_body = self.process_template(
13961437
raw, self.key, script_type, arg_placeholder,
13971438
context_file=self.context_file or "",
1439+
invoke_separator=self.invoke_separator,
13981440
)
13991441
# Strip the processed frontmatter — we rebuild it for skills.
14001442
# Preserve leading whitespace in the body to match release ZIP

src/specify_cli/integrations/copilot/__init__.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,16 @@ class CopilotIntegration(IntegrationBase):
103103
# Mutable flag set by setup() — indicates the active scaffolding mode.
104104
_skills_mode: bool = False
105105

106+
def effective_invoke_separator(
107+
self, parsed_options: dict[str, Any] | None = None
108+
) -> str:
109+
"""Return ``"-"`` when skills mode is requested, ``"."`` otherwise."""
110+
if parsed_options and parsed_options.get("skills"):
111+
return "-"
112+
if self._skills_mode:
113+
return "-"
114+
return self.invoke_separator
115+
106116
@classmethod
107117
def options(cls) -> list[IntegrationOption]:
108118
return [
@@ -145,9 +155,9 @@ def build_command_invocation(self, command_name: str, args: str = "") -> str:
145155
"""
146156
if self._skills_mode:
147157
stem = command_name
148-
if "." in stem:
149-
stem = stem.rsplit(".", 1)[-1]
150-
invocation = f"/speckit-{stem}"
158+
if stem.startswith("speckit."):
159+
stem = stem[len("speckit."):]
160+
invocation = "/speckit-" + stem.replace(".", "-")
151161
if args:
152162
invocation = f"{invocation} {args}"
153163
return invocation
@@ -175,8 +185,8 @@ def dispatch_command(
175185
import subprocess
176186

177187
stem = command_name
178-
if "." in stem:
179-
stem = stem.rsplit(".", 1)[-1]
188+
if stem.startswith("speckit."):
189+
stem = stem[len("speckit."):]
180190

181191
# Detect skills mode from project layout when not set via setup()
182192
skills_mode = self._skills_mode
@@ -189,7 +199,7 @@ def dispatch_command(
189199
)
190200

191201
if skills_mode:
192-
prompt = f"/speckit-{stem}"
202+
prompt = "/speckit-" + stem.replace(".", "-")
193203
if args:
194204
prompt = f"{prompt} {args}"
195205
else:

templates/checklist-template.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@
44
**Created**: [DATE]
55
**Feature**: [Link to spec.md or relevant documentation]
66

7-
**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements.
7+
**Note**: This checklist is generated by the `__SPECKIT_COMMAND_CHECKLIST__` command based on feature context and requirements.
88

99
<!--
1010
============================================================================
1111
IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.
1212
13-
The /speckit.checklist command MUST replace these with actual items based on:
13+
The __SPECKIT_COMMAND_CHECKLIST__ command MUST replace these with actual items based on:
1414
- User's specific checklist request
1515
- Feature requirements from spec.md
1616
- Technical context from plan.md

templates/commands/analyze.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,13 @@ You **MUST** consider the user input before proceeding (if not empty).
4949
5050
## Goal
5151
52-
Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit.tasks` has successfully produced a complete `tasks.md`.
52+
Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `__SPECKIT_COMMAND_TASKS__` has successfully produced a complete `tasks.md`.
5353
5454
## Operating Constraints
5555
5656
**STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually).
5757
58-
**Constitution Authority**: The project constitution (`/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit.analyze`.
58+
**Constitution Authority**: The project constitution (`/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `__SPECKIT_COMMAND_ANALYZE__`.
5959
6060
## Execution Steps
6161
@@ -191,9 +191,9 @@ Output a Markdown report (no file writes) with the following structure:
191191
192192
At end of report, output a concise Next Actions block:
193193
194-
- If CRITICAL issues exist: Recommend resolving before `/speckit.implement`
194+
- If CRITICAL issues exist: Recommend resolving before `__SPECKIT_COMMAND_IMPLEMENT__`
195195
- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions
196-
- Provide explicit command suggestions: e.g., "Run /speckit.specify with refinement", "Run /speckit.plan to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'"
196+
- Provide explicit command suggestions: e.g., "Run __SPECKIT_COMMAND_SPECIFY__ with refinement", "Run __SPECKIT_COMMAND_PLAN__ to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'"
197197
198198
### 8. Offer Remediation
199199

templates/commands/checklist.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ You **MUST** consider the user input before proceeding (if not empty).
249249
- Actor/timing
250250
- Any explicit user-specified must-have items incorporated
251251
252-
**Important**: Each `/speckit.checklist` command invocation uses a short, descriptive checklist filename and either creates a new file or appends to an existing one. This allows:
252+
**Important**: Each `__SPECKIT_COMMAND_CHECKLIST__` command invocation uses a short, descriptive checklist filename and either creates a new file or appends to an existing one. This allows:
253253
254254
- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`)
255255
- Simple, memorable filenames that indicate checklist purpose

templates/commands/clarify.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,15 @@ You **MUST** consider the user input before proceeding (if not empty).
5555
5656
Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.
5757
58-
Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `/speckit.plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases.
58+
Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `__SPECKIT_COMMAND_PLAN__`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases.
5959
6060
Execution steps:
6161
6262
1. Run `{SCRIPT}` from repo root **once** (combined `--json --paths-only` mode / `-Json -PathsOnly`). Parse minimal JSON payload fields:
6363
- `FEATURE_DIR`
6464
- `FEATURE_SPEC`
6565
- (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
66-
- If JSON parsing fails, abort and instruct user to re-run `/speckit.specify` or verify feature branch environment.
66+
- If JSON parsing fails, abort and instruct user to re-run `__SPECKIT_COMMAND_SPECIFY__` or verify feature branch environment.
6767
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
6868
6969
2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
@@ -202,13 +202,13 @@ Execution steps:
202202
- Path to updated spec.
203203
- Sections touched (list names).
204204
- Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).
205-
- If any Outstanding or Deferred remain, recommend whether to proceed to `/speckit.plan` or run `/speckit.clarify` again later post-plan.
205+
- If any Outstanding or Deferred remain, recommend whether to proceed to `__SPECKIT_COMMAND_PLAN__` or run `__SPECKIT_COMMAND_CLARIFY__` again later post-plan.
206206
- Suggested next command.
207207
208208
Behavior rules:
209209
210210
- If no meaningful ambiguities found (or all potential questions would be low-impact), respond: "No critical ambiguities detected worth formal clarification." and suggest proceeding.
211-
- If spec file missing, instruct user to run `/speckit.specify` first (do not create a new spec here).
211+
- If spec file missing, instruct user to run `__SPECKIT_COMMAND_SPECIFY__` first (do not create a new spec here).
212212
- Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions).
213213
- Avoid speculative tech stack questions unless the absence blocks functional clarity.
214214
- Respect user early termination signals ("stop", "done", "proceed").

0 commit comments

Comments
 (0)