mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
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:
@@ -723,6 +723,7 @@ def _install_shared_infra(
|
|||||||
script_type: str,
|
script_type: str,
|
||||||
tracker: StepTracker | None = None,
|
tracker: StepTracker | None = None,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
|
invoke_separator: str = ".",
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Install shared infrastructure files into *project_path*.
|
"""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
|
bundled core_pack or source checkout. Tracks all installed files
|
||||||
in ``speckit.manifest.json``.
|
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
|
When *force* is ``True``, existing files are overwritten with the
|
||||||
latest bundled versions. When ``False`` (default), only missing
|
latest bundled versions. When ``False`` (default), only missing
|
||||||
files are added and existing ones are skipped.
|
files are added and existing ones are skipped.
|
||||||
|
|
||||||
Returns ``True`` on success.
|
Returns ``True`` on success.
|
||||||
"""
|
"""
|
||||||
|
from .integrations.base import IntegrationBase
|
||||||
from .integrations.manifest import IntegrationManifest
|
from .integrations.manifest import IntegrationManifest
|
||||||
|
|
||||||
core = _locate_core_pack()
|
core = _locate_core_pack()
|
||||||
@@ -786,7 +792,11 @@ def _install_shared_infra(
|
|||||||
if dst.exists() and not force:
|
if dst.exists() and not force:
|
||||||
skipped_files.append(str(dst.relative_to(project_path)))
|
skipped_files.append(str(dst.relative_to(project_path)))
|
||||||
else:
|
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()
|
rel = dst.relative_to(project_path).as_posix()
|
||||||
manifest.record_existing(rel)
|
manifest.record_existing(rel)
|
||||||
|
|
||||||
@@ -1295,7 +1305,7 @@ def init(
|
|||||||
|
|
||||||
# Install shared infrastructure (scripts, templates)
|
# Install shared infrastructure (scripts, templates)
|
||||||
tracker.start("shared-infra")
|
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")
|
tracker.complete("shared-infra", f"scripts ({selected_script}) + templates")
|
||||||
|
|
||||||
ensure_constitution_from_template(project_path, tracker=tracker)
|
ensure_constitution_from_template(project_path, tracker=tracker)
|
||||||
@@ -2072,9 +2082,16 @@ def integration_install(
|
|||||||
|
|
||||||
selected_script = _resolve_script_type(project_root, script)
|
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;
|
# Ensure shared infrastructure is present (safe to run unconditionally;
|
||||||
# _install_shared_infra merges missing files without overwriting).
|
# _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":
|
if os.name != "nt":
|
||||||
ensure_executable_scripts(project_root)
|
ensure_executable_scripts(project_root)
|
||||||
|
|
||||||
@@ -2082,11 +2099,6 @@ def integration_install(
|
|||||||
integration.key, project_root, version=get_speckit_version()
|
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:
|
try:
|
||||||
integration.setup(
|
integration.setup(
|
||||||
project_root, manifest,
|
project_root, manifest,
|
||||||
@@ -2356,9 +2368,16 @@ def integration_switch(
|
|||||||
opts.pop("context_file", None)
|
opts.pop("context_file", None)
|
||||||
save_init_options(project_root, opts)
|
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;
|
# Ensure shared infrastructure is present (safe to run unconditionally;
|
||||||
# _install_shared_infra merges missing files without overwriting).
|
# _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":
|
if os.name != "nt":
|
||||||
ensure_executable_scripts(project_root)
|
ensure_executable_scripts(project_root)
|
||||||
|
|
||||||
@@ -2368,10 +2387,6 @@ def integration_switch(
|
|||||||
target_integration.key, project_root, version=get_speckit_version()
|
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:
|
try:
|
||||||
target_integration.setup(
|
target_integration.setup(
|
||||||
project_root, manifest,
|
project_root, manifest,
|
||||||
@@ -2465,8 +2480,15 @@ def integration_upgrade(
|
|||||||
|
|
||||||
selected_script = _resolve_script_type(project_root, script)
|
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.
|
# 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":
|
if os.name != "nt":
|
||||||
ensure_executable_scripts(project_root)
|
ensure_executable_scripts(project_root)
|
||||||
|
|
||||||
@@ -2474,10 +2496,6 @@ def integration_upgrade(
|
|||||||
console.print(f"Upgrading integration: [cyan]{key}[/cyan]")
|
console.print(f"Upgrading integration: [cyan]{key}[/cyan]")
|
||||||
new_manifest = IntegrationManifest(key, project_root, version=get_speckit_version())
|
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:
|
try:
|
||||||
integration.setup(
|
integration.setup(
|
||||||
project_root,
|
project_root,
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ class IntegrationBase(ABC):
|
|||||||
context_file: str | None = None
|
context_file: str | None = None
|
||||||
"""Relative path to the agent context file (e.g. ``CLAUDE.md``)."""
|
"""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 ------------------------------
|
# -- Markers for managed context section ------------------------------
|
||||||
|
|
||||||
CONTEXT_MARKER_START = "<!-- SPECKIT START -->"
|
CONTEXT_MARKER_START = "<!-- SPECKIT START -->"
|
||||||
@@ -96,6 +99,18 @@ class IntegrationBase(ABC):
|
|||||||
"""Return options this integration accepts. Default: none."""
|
"""Return options this integration accepts. Default: none."""
|
||||||
return []
|
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(
|
def build_exec_args(
|
||||||
self,
|
self,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
@@ -122,11 +137,12 @@ class IntegrationBase(ABC):
|
|||||||
agents or ``"/speckit-specify my-feature"`` for skills agents.
|
agents or ``"/speckit-specify my-feature"`` for skills agents.
|
||||||
|
|
||||||
*command_name* may be a full dotted name like
|
*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
|
stem = command_name
|
||||||
if "." in stem:
|
if stem.startswith("speckit."):
|
||||||
stem = stem.rsplit(".", 1)[-1]
|
stem = stem[len("speckit."):]
|
||||||
|
|
||||||
invocation = f"/speckit.{stem}"
|
invocation = f"/speckit.{stem}"
|
||||||
if args:
|
if args:
|
||||||
@@ -597,6 +613,24 @@ class IntegrationBase(ABC):
|
|||||||
|
|
||||||
return True
|
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
|
@staticmethod
|
||||||
def process_template(
|
def process_template(
|
||||||
content: str,
|
content: str,
|
||||||
@@ -604,6 +638,7 @@ class IntegrationBase(ABC):
|
|||||||
script_type: str,
|
script_type: str,
|
||||||
arg_placeholder: str = "$ARGUMENTS",
|
arg_placeholder: str = "$ARGUMENTS",
|
||||||
context_file: str = "",
|
context_file: str = "",
|
||||||
|
invoke_separator: str = ".",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Process a raw command template into agent-ready content.
|
"""Process a raw command template into agent-ready content.
|
||||||
|
|
||||||
@@ -615,6 +650,7 @@ class IntegrationBase(ABC):
|
|||||||
5. Replace ``__AGENT__`` with *agent_name*
|
5. Replace ``__AGENT__`` with *agent_name*
|
||||||
6. Replace ``__CONTEXT_FILE__`` with *context_file*
|
6. Replace ``__CONTEXT_FILE__`` with *context_file*
|
||||||
7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc.
|
7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc.
|
||||||
|
8. Replace ``__SPECKIT_COMMAND_<NAME>__`` with invocation strings
|
||||||
"""
|
"""
|
||||||
# 1. Extract script command from frontmatter
|
# 1. Extract script command from frontmatter
|
||||||
script_command = ""
|
script_command = ""
|
||||||
@@ -684,6 +720,9 @@ class IntegrationBase(ABC):
|
|||||||
|
|
||||||
content = CommandRegistrar.rewrite_project_relative_paths(content)
|
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
|
return content
|
||||||
|
|
||||||
def setup(
|
def setup(
|
||||||
@@ -1274,6 +1313,8 @@ class SkillsIntegration(IntegrationBase):
|
|||||||
``speckit-<name>/SKILL.md`` file with skills-oriented frontmatter.
|
``speckit-<name>/SKILL.md`` file with skills-oriented frontmatter.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
invoke_separator = "-"
|
||||||
|
|
||||||
def build_exec_args(
|
def build_exec_args(
|
||||||
self,
|
self,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
@@ -1311,10 +1352,10 @@ class SkillsIntegration(IntegrationBase):
|
|||||||
def build_command_invocation(self, command_name: str, args: str = "") -> str:
|
def build_command_invocation(self, command_name: str, args: str = "") -> str:
|
||||||
"""Skills use ``/speckit-<stem>`` (hyphenated directory name)."""
|
"""Skills use ``/speckit-<stem>`` (hyphenated directory name)."""
|
||||||
stem = command_name
|
stem = command_name
|
||||||
if "." in stem:
|
if stem.startswith("speckit."):
|
||||||
stem = stem.rsplit(".", 1)[-1]
|
stem = stem[len("speckit."):]
|
||||||
|
|
||||||
invocation = f"/speckit-{stem}"
|
invocation = "/speckit-" + stem.replace(".", "-")
|
||||||
if args:
|
if args:
|
||||||
invocation = f"{invocation} {args}"
|
invocation = f"{invocation} {args}"
|
||||||
return invocation
|
return invocation
|
||||||
@@ -1395,6 +1436,7 @@ class SkillsIntegration(IntegrationBase):
|
|||||||
processed_body = self.process_template(
|
processed_body = self.process_template(
|
||||||
raw, self.key, script_type, arg_placeholder,
|
raw, self.key, script_type, arg_placeholder,
|
||||||
context_file=self.context_file or "",
|
context_file=self.context_file or "",
|
||||||
|
invoke_separator=self.invoke_separator,
|
||||||
)
|
)
|
||||||
# Strip the processed frontmatter — we rebuild it for skills.
|
# Strip the processed frontmatter — we rebuild it for skills.
|
||||||
# Preserve leading whitespace in the body to match release ZIP
|
# Preserve leading whitespace in the body to match release ZIP
|
||||||
|
|||||||
@@ -103,6 +103,16 @@ class CopilotIntegration(IntegrationBase):
|
|||||||
# Mutable flag set by setup() — indicates the active scaffolding mode.
|
# Mutable flag set by setup() — indicates the active scaffolding mode.
|
||||||
_skills_mode: bool = False
|
_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
|
@classmethod
|
||||||
def options(cls) -> list[IntegrationOption]:
|
def options(cls) -> list[IntegrationOption]:
|
||||||
return [
|
return [
|
||||||
@@ -145,9 +155,9 @@ class CopilotIntegration(IntegrationBase):
|
|||||||
"""
|
"""
|
||||||
if self._skills_mode:
|
if self._skills_mode:
|
||||||
stem = command_name
|
stem = command_name
|
||||||
if "." in stem:
|
if stem.startswith("speckit."):
|
||||||
stem = stem.rsplit(".", 1)[-1]
|
stem = stem[len("speckit."):]
|
||||||
invocation = f"/speckit-{stem}"
|
invocation = "/speckit-" + stem.replace(".", "-")
|
||||||
if args:
|
if args:
|
||||||
invocation = f"{invocation} {args}"
|
invocation = f"{invocation} {args}"
|
||||||
return invocation
|
return invocation
|
||||||
@@ -175,8 +185,8 @@ class CopilotIntegration(IntegrationBase):
|
|||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
stem = command_name
|
stem = command_name
|
||||||
if "." in stem:
|
if stem.startswith("speckit."):
|
||||||
stem = stem.rsplit(".", 1)[-1]
|
stem = stem[len("speckit."):]
|
||||||
|
|
||||||
# Detect skills mode from project layout when not set via setup()
|
# Detect skills mode from project layout when not set via setup()
|
||||||
skills_mode = self._skills_mode
|
skills_mode = self._skills_mode
|
||||||
@@ -189,7 +199,7 @@ class CopilotIntegration(IntegrationBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if skills_mode:
|
if skills_mode:
|
||||||
prompt = f"/speckit-{stem}"
|
prompt = "/speckit-" + stem.replace(".", "-")
|
||||||
if args:
|
if args:
|
||||||
prompt = f"{prompt} {args}"
|
prompt = f"{prompt} {args}"
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -4,13 +4,13 @@
|
|||||||
**Created**: [DATE]
|
**Created**: [DATE]
|
||||||
**Feature**: [Link to spec.md or relevant documentation]
|
**Feature**: [Link to spec.md or relevant documentation]
|
||||||
|
|
||||||
**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements.
|
**Note**: This checklist is generated by the `__SPECKIT_COMMAND_CHECKLIST__` command based on feature context and requirements.
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
============================================================================
|
============================================================================
|
||||||
IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.
|
IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.
|
||||||
|
|
||||||
The /speckit.checklist command MUST replace these with actual items based on:
|
The __SPECKIT_COMMAND_CHECKLIST__ command MUST replace these with actual items based on:
|
||||||
- User's specific checklist request
|
- User's specific checklist request
|
||||||
- Feature requirements from spec.md
|
- Feature requirements from spec.md
|
||||||
- Technical context from plan.md
|
- Technical context from plan.md
|
||||||
|
|||||||
@@ -49,13 +49,13 @@ You **MUST** consider the user input before proceeding (if not empty).
|
|||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
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`.
|
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`.
|
||||||
|
|
||||||
## Operating Constraints
|
## Operating Constraints
|
||||||
|
|
||||||
**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).
|
**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).
|
||||||
|
|
||||||
**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`.
|
**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__`.
|
||||||
|
|
||||||
## Execution Steps
|
## Execution Steps
|
||||||
|
|
||||||
@@ -191,9 +191,9 @@ Output a Markdown report (no file writes) with the following structure:
|
|||||||
|
|
||||||
At end of report, output a concise Next Actions block:
|
At end of report, output a concise Next Actions block:
|
||||||
|
|
||||||
- If CRITICAL issues exist: Recommend resolving before `/speckit.implement`
|
- If CRITICAL issues exist: Recommend resolving before `__SPECKIT_COMMAND_IMPLEMENT__`
|
||||||
- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions
|
- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions
|
||||||
- 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'"
|
- 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'"
|
||||||
|
|
||||||
### 8. Offer Remediation
|
### 8. Offer Remediation
|
||||||
|
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
|||||||
- Actor/timing
|
- Actor/timing
|
||||||
- Any explicit user-specified must-have items incorporated
|
- Any explicit user-specified must-have items incorporated
|
||||||
|
|
||||||
**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:
|
**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:
|
||||||
|
|
||||||
- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`)
|
- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`)
|
||||||
- Simple, memorable filenames that indicate checklist purpose
|
- Simple, memorable filenames that indicate checklist purpose
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
|||||||
|
|
||||||
Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.
|
Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
Execution steps:
|
Execution steps:
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ Execution steps:
|
|||||||
- `FEATURE_DIR`
|
- `FEATURE_DIR`
|
||||||
- `FEATURE_SPEC`
|
- `FEATURE_SPEC`
|
||||||
- (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
|
- (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
|
||||||
- If JSON parsing fails, abort and instruct user to re-run `/speckit.specify` or verify feature branch environment.
|
- If JSON parsing fails, abort and instruct user to re-run `__SPECKIT_COMMAND_SPECIFY__` or verify feature branch environment.
|
||||||
- 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").
|
- 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").
|
||||||
|
|
||||||
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).
|
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:
|
|||||||
- Path to updated spec.
|
- Path to updated spec.
|
||||||
- Sections touched (list names).
|
- Sections touched (list names).
|
||||||
- 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).
|
- 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).
|
||||||
- If any Outstanding or Deferred remain, recommend whether to proceed to `/speckit.plan` or run `/speckit.clarify` again later post-plan.
|
- If any Outstanding or Deferred remain, recommend whether to proceed to `__SPECKIT_COMMAND_PLAN__` or run `__SPECKIT_COMMAND_CLARIFY__` again later post-plan.
|
||||||
- Suggested next command.
|
- Suggested next command.
|
||||||
|
|
||||||
Behavior rules:
|
Behavior rules:
|
||||||
|
|
||||||
- If no meaningful ambiguities found (or all potential questions would be low-impact), respond: "No critical ambiguities detected worth formal clarification." and suggest proceeding.
|
- If no meaningful ambiguities found (or all potential questions would be low-impact), respond: "No critical ambiguities detected worth formal clarification." and suggest proceeding.
|
||||||
- If spec file missing, instruct user to run `/speckit.specify` first (do not create a new spec here).
|
- If spec file missing, instruct user to run `__SPECKIT_COMMAND_SPECIFY__` first (do not create a new spec here).
|
||||||
- Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions).
|
- Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions).
|
||||||
- Avoid speculative tech stack questions unless the absence blocks functional clarity.
|
- Avoid speculative tech stack questions unless the absence blocks functional clarity.
|
||||||
- Respect user early termination signals ("stop", "done", "proceed").
|
- Respect user early termination signals ("stop", "done", "proceed").
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
|||||||
- Confirm the implementation follows the technical plan
|
- Confirm the implementation follows the technical plan
|
||||||
- Report final status with summary of completed work
|
- Report final status with summary of completed work
|
||||||
|
|
||||||
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit.tasks` first to regenerate the task list.
|
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `__SPECKIT_COMMAND_TASKS__` first to regenerate the task list.
|
||||||
|
|
||||||
10. **Check for extension hooks**: After completion validation, check if `.specify/extensions.yml` exists in the project root.
|
10. **Check for extension hooks**: After completion validation, check if `.specify/extensions.yml` exists in the project root.
|
||||||
- If it exists, read it and look for entries under the `hooks.after_implement` key
|
- If it exists, read it and look for entries under the `hooks.after_implement` key
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
|||||||
|
|
||||||
## Outline
|
## Outline
|
||||||
|
|
||||||
The text the user typed after `/speckit.specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `{ARGS}` appears literally below. Do not ask the user to repeat it unless they provided an empty command.
|
The text the user typed after `__SPECKIT_COMMAND_SPECIFY__` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `{ARGS}` appears literally below. Do not ask the user to repeat it unless they provided an empty command.
|
||||||
|
|
||||||
Given that feature description, do this:
|
Given that feature description, do this:
|
||||||
|
|
||||||
@@ -100,10 +100,10 @@ Given that feature description, do this:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
Write the actual resolved directory path value (for example, `specs/003-user-auth`), not the literal string `SPECIFY_FEATURE_DIRECTORY`.
|
Write the actual resolved directory path value (for example, `specs/003-user-auth`), not the literal string `SPECIFY_FEATURE_DIRECTORY`.
|
||||||
This allows downstream commands (`/speckit.plan`, `/speckit.tasks`, etc.) to locate the feature directory without relying on git branch name conventions.
|
This allows downstream commands (`__SPECKIT_COMMAND_PLAN__`, `__SPECKIT_COMMAND_TASKS__`, etc.) to locate the feature directory without relying on git branch name conventions.
|
||||||
|
|
||||||
**IMPORTANT**:
|
**IMPORTANT**:
|
||||||
- You must only create one feature per `/speckit.specify` invocation
|
- You must only create one feature per `__SPECKIT_COMMAND_SPECIFY__` invocation
|
||||||
- The spec directory name and the git branch name are independent — they may be the same but that is the user's choice
|
- The spec directory name and the git branch name are independent — they may be the same but that is the user's choice
|
||||||
- The spec directory and file are always created by this command, never by the hook
|
- The spec directory and file are always created by this command, never by the hook
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ Given that feature description, do this:
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
- Items marked incomplete require spec updates before `__SPECKIT_COMMAND_CLARIFY__` or `__SPECKIT_COMMAND_PLAN__`
|
||||||
```
|
```
|
||||||
|
|
||||||
b. **Run Validation Check**: Review the spec against each checklist item:
|
b. **Run Validation Check**: Review the spec against each checklist item:
|
||||||
@@ -232,7 +232,7 @@ Given that feature description, do this:
|
|||||||
- `SPECIFY_FEATURE_DIRECTORY` — the feature directory path
|
- `SPECIFY_FEATURE_DIRECTORY` — the feature directory path
|
||||||
- `SPEC_FILE` — the spec file path
|
- `SPEC_FILE` — the spec file path
|
||||||
- Checklist results summary
|
- Checklist results summary
|
||||||
- Readiness for the next phase (`/speckit.clarify` or `/speckit.plan`)
|
- Readiness for the next phase (`__SPECKIT_COMMAND_CLARIFY__` or `__SPECKIT_COMMAND_PLAN__`)
|
||||||
|
|
||||||
9. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root.
|
9. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root.
|
||||||
- If it exists, read it and look for entries under the `hooks.after_specify` key
|
- If it exists, read it and look for entries under the `hooks.after_specify` key
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
|
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
|
||||||
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
|
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
|
||||||
|
|
||||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/plan-template.md` for the execution workflow.
|
**Note**: This template is filled in by the `__SPECKIT_COMMAND_PLAN__` command. See `.specify/templates/plan-template.md` for the execution workflow.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -39,12 +39,12 @@
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
specs/[###-feature]/
|
specs/[###-feature]/
|
||||||
├── plan.md # This file (/speckit.plan command output)
|
├── plan.md # This file (__SPECKIT_COMMAND_PLAN__ command output)
|
||||||
├── research.md # Phase 0 output (/speckit.plan command)
|
├── research.md # Phase 0 output (__SPECKIT_COMMAND_PLAN__ command)
|
||||||
├── data-model.md # Phase 1 output (/speckit.plan command)
|
├── data-model.md # Phase 1 output (__SPECKIT_COMMAND_PLAN__ command)
|
||||||
├── quickstart.md # Phase 1 output (/speckit.plan command)
|
├── quickstart.md # Phase 1 output (__SPECKIT_COMMAND_PLAN__ command)
|
||||||
├── contracts/ # Phase 1 output (/speckit.plan command)
|
├── contracts/ # Phase 1 output (__SPECKIT_COMMAND_PLAN__ command)
|
||||||
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
|
└── tasks.md # Phase 2 output (__SPECKIT_COMMAND_TASKS__ command - NOT created by __SPECKIT_COMMAND_PLAN__)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Source Code (repository root)
|
### Source Code (repository root)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ description: "Task list template for feature implementation"
|
|||||||
============================================================================
|
============================================================================
|
||||||
IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only.
|
IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only.
|
||||||
|
|
||||||
The /speckit.tasks command MUST replace these with actual tasks based on:
|
The __SPECKIT_COMMAND_TASKS__ command MUST replace these with actual tasks based on:
|
||||||
- User stories from spec.md (with their priorities P1, P2, P3...)
|
- User stories from spec.md (with their priorities P1, P2, P3...)
|
||||||
- Feature requirements from plan.md
|
- Feature requirements from plan.md
|
||||||
- Entities from data-model.md
|
- Entities from data-model.md
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from specify_cli.integrations.base import (
|
|||||||
IntegrationBase,
|
IntegrationBase,
|
||||||
IntegrationOption,
|
IntegrationOption,
|
||||||
MarkdownIntegration,
|
MarkdownIntegration,
|
||||||
|
SkillsIntegration,
|
||||||
)
|
)
|
||||||
from specify_cli.integrations.manifest import IntegrationManifest
|
from specify_cli.integrations.manifest import IntegrationManifest
|
||||||
from .conftest import StubIntegration
|
from .conftest import StubIntegration
|
||||||
@@ -167,3 +168,130 @@ class TestBasePrimitives:
|
|||||||
assert f.parent.name == "commands"
|
assert f.parent.name == "commands"
|
||||||
assert f.name.startswith("speckit.")
|
assert f.name.startswith("speckit.")
|
||||||
assert f.name.endswith(".md")
|
assert f.name.endswith(".md")
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildCommandInvocation:
|
||||||
|
"""Tests for build_command_invocation across integration types."""
|
||||||
|
|
||||||
|
def test_base_core_command_dotted(self):
|
||||||
|
i = StubIntegration()
|
||||||
|
assert i.build_command_invocation("speckit.plan") == "/speckit.plan"
|
||||||
|
|
||||||
|
def test_base_core_command_bare(self):
|
||||||
|
i = StubIntegration()
|
||||||
|
assert i.build_command_invocation("plan") == "/speckit.plan"
|
||||||
|
|
||||||
|
def test_base_core_command_with_args(self):
|
||||||
|
i = StubIntegration()
|
||||||
|
assert i.build_command_invocation("plan", "my feature") == "/speckit.plan my feature"
|
||||||
|
|
||||||
|
def test_base_extension_command(self):
|
||||||
|
i = StubIntegration()
|
||||||
|
assert i.build_command_invocation("speckit.git.commit") == "/speckit.git.commit"
|
||||||
|
|
||||||
|
def test_base_extension_command_bare(self):
|
||||||
|
i = StubIntegration()
|
||||||
|
assert i.build_command_invocation("git.commit") == "/speckit.git.commit"
|
||||||
|
|
||||||
|
def test_skills_core_command(self):
|
||||||
|
from specify_cli.integrations import get_integration
|
||||||
|
i = get_integration("codex")
|
||||||
|
assert i.build_command_invocation("speckit.plan") == "/speckit-plan"
|
||||||
|
assert i.build_command_invocation("plan") == "/speckit-plan"
|
||||||
|
|
||||||
|
def test_skills_extension_command(self):
|
||||||
|
from specify_cli.integrations import get_integration
|
||||||
|
i = get_integration("codex")
|
||||||
|
assert i.build_command_invocation("speckit.git.commit") == "/speckit-git-commit"
|
||||||
|
assert i.build_command_invocation("git.commit") == "/speckit-git-commit"
|
||||||
|
|
||||||
|
def test_skills_extension_command_with_args(self):
|
||||||
|
from specify_cli.integrations import get_integration
|
||||||
|
i = get_integration("codex")
|
||||||
|
assert i.build_command_invocation("speckit.git.commit", "fix typo") == "/speckit-git-commit fix typo"
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveCommandRefs:
|
||||||
|
"""Tests for __SPECKIT_COMMAND_<NAME>__ placeholder resolution."""
|
||||||
|
|
||||||
|
def test_dot_separator_core_command(self):
|
||||||
|
text = "Run `__SPECKIT_COMMAND_PLAN__` to plan."
|
||||||
|
result = IntegrationBase.resolve_command_refs(text, ".")
|
||||||
|
assert result == "Run `/speckit.plan` to plan."
|
||||||
|
|
||||||
|
def test_hyphen_separator_core_command(self):
|
||||||
|
text = "Run `__SPECKIT_COMMAND_PLAN__` to plan."
|
||||||
|
result = IntegrationBase.resolve_command_refs(text, "-")
|
||||||
|
assert result == "Run `/speckit-plan` to plan."
|
||||||
|
|
||||||
|
def test_multiple_placeholders(self):
|
||||||
|
text = "__SPECKIT_COMMAND_SPECIFY__ then __SPECKIT_COMMAND_PLAN__ then __SPECKIT_COMMAND_TASKS__"
|
||||||
|
result = IntegrationBase.resolve_command_refs(text, ".")
|
||||||
|
assert result == "/speckit.specify then /speckit.plan then /speckit.tasks"
|
||||||
|
|
||||||
|
def test_extension_command_dot(self):
|
||||||
|
text = "Run __SPECKIT_COMMAND_GIT_COMMIT__ to commit."
|
||||||
|
result = IntegrationBase.resolve_command_refs(text, ".")
|
||||||
|
assert result == "Run /speckit.git.commit to commit."
|
||||||
|
|
||||||
|
def test_extension_command_hyphen(self):
|
||||||
|
text = "Run __SPECKIT_COMMAND_GIT_COMMIT__ to commit."
|
||||||
|
result = IntegrationBase.resolve_command_refs(text, "-")
|
||||||
|
assert result == "Run /speckit-git-commit to commit."
|
||||||
|
|
||||||
|
def test_no_placeholders_unchanged(self):
|
||||||
|
text = "No placeholders here."
|
||||||
|
assert IntegrationBase.resolve_command_refs(text, ".") == text
|
||||||
|
|
||||||
|
def test_default_separator_is_dot(self):
|
||||||
|
text = "__SPECKIT_COMMAND_PLAN__"
|
||||||
|
assert IntegrationBase.resolve_command_refs(text) == "/speckit.plan"
|
||||||
|
|
||||||
|
def test_invoke_separator_class_attribute(self):
|
||||||
|
assert IntegrationBase.invoke_separator == "."
|
||||||
|
assert SkillsIntegration.invoke_separator == "-"
|
||||||
|
|
||||||
|
def test_effective_invoke_separator_default(self):
|
||||||
|
"""Base classes return invoke_separator regardless of parsed_options."""
|
||||||
|
from .conftest import StubIntegration
|
||||||
|
stub = StubIntegration()
|
||||||
|
assert stub.effective_invoke_separator() == "."
|
||||||
|
assert stub.effective_invoke_separator({"skills": True}) == "."
|
||||||
|
|
||||||
|
def test_process_template_resolves_placeholders(self):
|
||||||
|
content = "---\ndescription: test\n---\nRun __SPECKIT_COMMAND_PLAN__ now."
|
||||||
|
result = IntegrationBase.process_template(
|
||||||
|
content, "test-agent", "sh", invoke_separator="."
|
||||||
|
)
|
||||||
|
assert "/speckit.plan" in result
|
||||||
|
assert "__SPECKIT_COMMAND_" not in result
|
||||||
|
|
||||||
|
def test_process_template_skills_separator(self):
|
||||||
|
content = "---\ndescription: test\n---\nRun __SPECKIT_COMMAND_PLAN__ now."
|
||||||
|
result = IntegrationBase.process_template(
|
||||||
|
content, "test-agent", "sh", invoke_separator="-"
|
||||||
|
)
|
||||||
|
assert "/speckit-plan" in result
|
||||||
|
assert "__SPECKIT_COMMAND_" not in result
|
||||||
|
|
||||||
|
def test_unclosed_placeholder_unchanged(self):
|
||||||
|
text = "Run __SPECKIT_COMMAND_PLAN to plan."
|
||||||
|
assert IntegrationBase.resolve_command_refs(text, ".") == text
|
||||||
|
|
||||||
|
def test_empty_name_not_matched(self):
|
||||||
|
text = "Run __SPECKIT_COMMAND___ to plan."
|
||||||
|
assert IntegrationBase.resolve_command_refs(text, ".") == text
|
||||||
|
|
||||||
|
def test_lowercase_placeholder_not_matched(self):
|
||||||
|
text = "Run __SPECKIT_COMMAND_plan__ to plan."
|
||||||
|
assert IntegrationBase.resolve_command_refs(text, ".") == text
|
||||||
|
|
||||||
|
def test_placeholder_adjacent_to_text(self):
|
||||||
|
text = "foo__SPECKIT_COMMAND_PLAN__bar"
|
||||||
|
result = IntegrationBase.resolve_command_refs(text, ".")
|
||||||
|
assert result == "foo/speckit.planbar"
|
||||||
|
|
||||||
|
def test_placeholder_with_digits(self):
|
||||||
|
text = "__SPECKIT_COMMAND_V2_PLAN__"
|
||||||
|
result = IntegrationBase.resolve_command_refs(text, ".")
|
||||||
|
assert result == "/speckit.v2.plan"
|
||||||
|
|||||||
@@ -471,3 +471,133 @@ class TestGitExtensionAutoInstall:
|
|||||||
assert claude_skills.exists(), "Claude skills directory was not created"
|
assert claude_skills.exists(), "Claude skills directory was not created"
|
||||||
git_skills = [f for f in claude_skills.iterdir() if f.name.startswith("speckit-git-")]
|
git_skills = [f for f in claude_skills.iterdir() if f.name.startswith("speckit-git-")]
|
||||||
assert len(git_skills) > 0, "no git extension commands registered"
|
assert len(git_skills) > 0, "no git extension commands registered"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSharedInfraCommandRefs:
|
||||||
|
"""Verify _install_shared_infra resolves __SPECKIT_COMMAND_*__ in page templates."""
|
||||||
|
|
||||||
|
def test_dot_separator_in_page_templates(self, tmp_path):
|
||||||
|
"""Markdown agents get /speckit.<name> in page templates."""
|
||||||
|
from specify_cli import _install_shared_infra
|
||||||
|
|
||||||
|
project = tmp_path / "dot-test"
|
||||||
|
project.mkdir()
|
||||||
|
(project / ".specify").mkdir()
|
||||||
|
|
||||||
|
_install_shared_infra(project, "sh", invoke_separator=".")
|
||||||
|
|
||||||
|
plan = project / ".specify" / "templates" / "plan-template.md"
|
||||||
|
assert plan.exists()
|
||||||
|
content = plan.read_text(encoding="utf-8")
|
||||||
|
assert "__SPECKIT_COMMAND_" not in content, "unresolved placeholder in plan-template.md"
|
||||||
|
assert "/speckit.plan" in content
|
||||||
|
|
||||||
|
checklist = project / ".specify" / "templates" / "checklist-template.md"
|
||||||
|
content = checklist.read_text(encoding="utf-8")
|
||||||
|
assert "__SPECKIT_COMMAND_" not in content
|
||||||
|
assert "/speckit.checklist" in content
|
||||||
|
|
||||||
|
def test_hyphen_separator_in_page_templates(self, tmp_path):
|
||||||
|
"""Skills agents get /speckit-<name> in page templates."""
|
||||||
|
from specify_cli import _install_shared_infra
|
||||||
|
|
||||||
|
project = tmp_path / "hyphen-test"
|
||||||
|
project.mkdir()
|
||||||
|
(project / ".specify").mkdir()
|
||||||
|
|
||||||
|
_install_shared_infra(project, "sh", invoke_separator="-")
|
||||||
|
|
||||||
|
plan = project / ".specify" / "templates" / "plan-template.md"
|
||||||
|
assert plan.exists()
|
||||||
|
content = plan.read_text(encoding="utf-8")
|
||||||
|
assert "__SPECKIT_COMMAND_" not in content, "unresolved placeholder in plan-template.md"
|
||||||
|
assert "/speckit-plan" in content
|
||||||
|
assert "/speckit.plan" not in content, "dot-notation leaked into skills page template"
|
||||||
|
|
||||||
|
tasks = project / ".specify" / "templates" / "tasks-template.md"
|
||||||
|
content = tasks.read_text(encoding="utf-8")
|
||||||
|
assert "__SPECKIT_COMMAND_" not in content
|
||||||
|
assert "/speckit-tasks" in content
|
||||||
|
|
||||||
|
def test_full_init_claude_resolves_page_templates(self, tmp_path):
|
||||||
|
"""Full CLI init with Claude (skills agent) produces hyphen refs in page templates."""
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
from specify_cli import app
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
project = tmp_path / "init-claude"
|
||||||
|
old_cwd = os.getcwd()
|
||||||
|
try:
|
||||||
|
os.chdir(tmp_path)
|
||||||
|
result = runner.invoke(app, [
|
||||||
|
"init", str(project),
|
||||||
|
"--integration", "claude",
|
||||||
|
"--script", "sh",
|
||||||
|
"--no-git",
|
||||||
|
"--ignore-agent-tools",
|
||||||
|
], catch_exceptions=False)
|
||||||
|
finally:
|
||||||
|
os.chdir(old_cwd)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||||
|
|
||||||
|
plan = project / ".specify" / "templates" / "plan-template.md"
|
||||||
|
content = plan.read_text(encoding="utf-8")
|
||||||
|
assert "/speckit-plan" in content, "Claude (skills) should use /speckit-plan"
|
||||||
|
assert "__SPECKIT_COMMAND_" not in content
|
||||||
|
|
||||||
|
def test_full_init_copilot_resolves_page_templates(self, tmp_path):
|
||||||
|
"""Full CLI init with Copilot (markdown agent) produces dot refs in page templates."""
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
from specify_cli import app
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
project = tmp_path / "init-copilot"
|
||||||
|
old_cwd = os.getcwd()
|
||||||
|
try:
|
||||||
|
os.chdir(tmp_path)
|
||||||
|
result = runner.invoke(app, [
|
||||||
|
"init", str(project),
|
||||||
|
"--integration", "copilot",
|
||||||
|
"--script", "sh",
|
||||||
|
"--no-git",
|
||||||
|
"--ignore-agent-tools",
|
||||||
|
], catch_exceptions=False)
|
||||||
|
finally:
|
||||||
|
os.chdir(old_cwd)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||||
|
|
||||||
|
plan = project / ".specify" / "templates" / "plan-template.md"
|
||||||
|
content = plan.read_text(encoding="utf-8")
|
||||||
|
assert "/speckit.plan" in content, "Copilot (markdown) should use /speckit.plan"
|
||||||
|
assert "__SPECKIT_COMMAND_" not in content
|
||||||
|
|
||||||
|
def test_full_init_copilot_skills_resolves_page_templates(self, tmp_path):
|
||||||
|
"""Full CLI init with Copilot --skills produces hyphen refs in page templates."""
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
from specify_cli import app
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
project = tmp_path / "init-copilot-skills"
|
||||||
|
old_cwd = os.getcwd()
|
||||||
|
try:
|
||||||
|
os.chdir(tmp_path)
|
||||||
|
result = runner.invoke(app, [
|
||||||
|
"init", str(project),
|
||||||
|
"--integration", "copilot",
|
||||||
|
"--integration-options", "--skills",
|
||||||
|
"--script", "sh",
|
||||||
|
"--no-git",
|
||||||
|
"--ignore-agent-tools",
|
||||||
|
], catch_exceptions=False)
|
||||||
|
finally:
|
||||||
|
os.chdir(old_cwd)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||||
|
|
||||||
|
plan = project / ".specify" / "templates" / "plan-template.md"
|
||||||
|
content = plan.read_text(encoding="utf-8")
|
||||||
|
assert "/speckit-plan" in content, "Copilot --skills should use /speckit-plan"
|
||||||
|
assert "/speckit.plan" not in content, "dot-notation leaked into Copilot skills page template"
|
||||||
|
assert "__SPECKIT_COMMAND_" not in content
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ class MarkdownIntegrationTests:
|
|||||||
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
||||||
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
||||||
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
||||||
|
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
|
||||||
assert "\nscripts:\n" not in content, f"{f.name} has unstripped scripts: block"
|
assert "\nscripts:\n" not in content, f"{f.name} has unstripped scripts: block"
|
||||||
|
|
||||||
def test_plan_references_correct_context_file(self, tmp_path):
|
def test_plan_references_correct_context_file(self, tmp_path):
|
||||||
|
|||||||
@@ -159,6 +159,22 @@ class SkillsIntegrationTests:
|
|||||||
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
||||||
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
||||||
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
||||||
|
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
|
||||||
|
|
||||||
|
def test_command_refs_use_hyphen_separator(self, tmp_path):
|
||||||
|
"""Skills agents must resolve command refs with hyphen separator."""
|
||||||
|
i = get_integration(self.KEY)
|
||||||
|
m = IntegrationManifest(self.KEY, tmp_path)
|
||||||
|
created = i.setup(tmp_path, m)
|
||||||
|
skill_files = [f for f in created if "scripts" not in f.parts]
|
||||||
|
assert len(skill_files) > 0
|
||||||
|
for f in skill_files:
|
||||||
|
content = f.read_text(encoding="utf-8")
|
||||||
|
# Skills agents must use /speckit-<name>, not /speckit.<name>
|
||||||
|
assert "/speckit." not in content, (
|
||||||
|
f"{f.name} contains dot-notation /speckit. reference; "
|
||||||
|
f"skills agents must use /speckit-<name>"
|
||||||
|
)
|
||||||
|
|
||||||
def test_skill_body_has_content(self, tmp_path):
|
def test_skill_body_has_content(self, tmp_path):
|
||||||
"""Each SKILL.md body should contain template content after the frontmatter."""
|
"""Each SKILL.md body should contain template content after the frontmatter."""
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ class TomlIntegrationTests:
|
|||||||
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
||||||
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
||||||
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
||||||
|
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
|
||||||
|
|
||||||
def test_toml_has_description(self, tmp_path):
|
def test_toml_has_description(self, tmp_path):
|
||||||
"""Every TOML command file should have a description key."""
|
"""Every TOML command file should have a description key."""
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ class YamlIntegrationTests:
|
|||||||
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
||||||
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
||||||
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
||||||
|
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
|
||||||
|
|
||||||
def test_yaml_has_title(self, tmp_path):
|
def test_yaml_has_title(self, tmp_path):
|
||||||
"""Every YAML recipe should have a title field."""
|
"""Every YAML recipe should have a title field."""
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ class TestClaudeIntegration:
|
|||||||
assert "{SCRIPT}" not in content
|
assert "{SCRIPT}" not in content
|
||||||
assert "{ARGS}" not in content
|
assert "{ARGS}" not in content
|
||||||
assert "__AGENT__" not in content
|
assert "__AGENT__" not in content
|
||||||
|
assert "__SPECKIT_COMMAND_" not in content, "unprocessed __SPECKIT_COMMAND_*__"
|
||||||
|
assert "/speckit." not in content, "skills agent must use /speckit-<name> not /speckit.<name>"
|
||||||
|
|
||||||
parts = content.split("---", 2)
|
parts = content.split("---", 2)
|
||||||
parsed = yaml.safe_load(parts[1])
|
parsed = yaml.safe_load(parts[1])
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ class TestCopilotIntegration:
|
|||||||
assert "{SCRIPT}" not in content, f"{agent_file.name} has unprocessed {{SCRIPT}}"
|
assert "{SCRIPT}" not in content, f"{agent_file.name} has unprocessed {{SCRIPT}}"
|
||||||
assert "__AGENT__" not in content, f"{agent_file.name} has unprocessed __AGENT__"
|
assert "__AGENT__" not in content, f"{agent_file.name} has unprocessed __AGENT__"
|
||||||
assert "{ARGS}" not in content, f"{agent_file.name} has unprocessed {{ARGS}}"
|
assert "{ARGS}" not in content, f"{agent_file.name} has unprocessed {{ARGS}}"
|
||||||
|
assert "__SPECKIT_COMMAND_" not in content, f"{agent_file.name} has unprocessed __SPECKIT_COMMAND_*__"
|
||||||
assert "\nscripts:\n" not in content
|
assert "\nscripts:\n" not in content
|
||||||
|
|
||||||
def test_plan_references_correct_context_file(self, tmp_path):
|
def test_plan_references_correct_context_file(self, tmp_path):
|
||||||
@@ -444,6 +445,27 @@ class TestCopilotSkillsMode:
|
|||||||
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
||||||
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
||||||
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
||||||
|
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
|
||||||
|
|
||||||
|
def test_skills_command_refs_use_hyphen(self, tmp_path):
|
||||||
|
"""Copilot skills mode must use /speckit-<name> not /speckit.<name>."""
|
||||||
|
copilot = self._make_copilot()
|
||||||
|
created, _ = self._setup_skills(copilot, tmp_path)
|
||||||
|
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||||
|
assert len(skill_files) > 0
|
||||||
|
for f in skill_files:
|
||||||
|
content = f.read_text(encoding="utf-8")
|
||||||
|
assert "/speckit." not in content, (
|
||||||
|
f"{f.name} contains dot-notation /speckit. reference; "
|
||||||
|
f"skills mode must use /speckit-<name>"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_skills_mode_invoke_separator(self):
|
||||||
|
"""Copilot effective_invoke_separator should reflect skills mode."""
|
||||||
|
copilot = self._make_copilot()
|
||||||
|
assert copilot.effective_invoke_separator() == "."
|
||||||
|
assert copilot.effective_invoke_separator({"skills": True}) == "-"
|
||||||
|
assert copilot.effective_invoke_separator({"skills": False}) == "."
|
||||||
|
|
||||||
def test_skill_body_has_content(self, tmp_path):
|
def test_skill_body_has_content(self, tmp_path):
|
||||||
"""Each SKILL.md body should contain template content."""
|
"""Each SKILL.md body should contain template content."""
|
||||||
@@ -509,6 +531,12 @@ class TestCopilotSkillsMode:
|
|||||||
assert copilot.build_command_invocation("plan") == "/speckit-plan"
|
assert copilot.build_command_invocation("plan") == "/speckit-plan"
|
||||||
assert copilot.build_command_invocation("plan", "my args") == "/speckit-plan my args"
|
assert copilot.build_command_invocation("plan", "my args") == "/speckit-plan my args"
|
||||||
|
|
||||||
|
def test_build_command_invocation_skills_extension_command(self):
|
||||||
|
copilot = self._make_copilot()
|
||||||
|
copilot._skills_mode = True
|
||||||
|
assert copilot.build_command_invocation("speckit.git.commit") == "/speckit-git-commit"
|
||||||
|
assert copilot.build_command_invocation("git.commit") == "/speckit-git-commit"
|
||||||
|
|
||||||
def test_build_command_invocation_default_mode(self):
|
def test_build_command_invocation_default_mode(self):
|
||||||
copilot = self._make_copilot()
|
copilot = self._make_copilot()
|
||||||
assert copilot.build_command_invocation("plan", "my args") == "my args"
|
assert copilot.build_command_invocation("plan", "my args") == "my args"
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ class TestForgeIntegration:
|
|||||||
assert "{SCRIPT}" not in content, f"{cmd_file.name} has unprocessed {{SCRIPT}}"
|
assert "{SCRIPT}" not in content, f"{cmd_file.name} has unprocessed {{SCRIPT}}"
|
||||||
assert "__AGENT__" not in content, f"{cmd_file.name} has unprocessed __AGENT__"
|
assert "__AGENT__" not in content, f"{cmd_file.name} has unprocessed __AGENT__"
|
||||||
assert "{ARGS}" not in content, f"{cmd_file.name} has unprocessed {{ARGS}}"
|
assert "{ARGS}" not in content, f"{cmd_file.name} has unprocessed {{ARGS}}"
|
||||||
|
assert "__SPECKIT_COMMAND_" not in content, f"{cmd_file.name} has unprocessed __SPECKIT_COMMAND_*__"
|
||||||
# Check Forge-specific: $ARGUMENTS should be replaced with {{parameters}}
|
# Check Forge-specific: $ARGUMENTS should be replaced with {{parameters}}
|
||||||
assert "$ARGUMENTS" not in content, f"{cmd_file.name} has unprocessed $ARGUMENTS"
|
assert "$ARGUMENTS" not in content, f"{cmd_file.name} has unprocessed $ARGUMENTS"
|
||||||
# Frontmatter sections should be stripped
|
# Frontmatter sections should be stripped
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ class TestGenericIntegration:
|
|||||||
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
||||||
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
||||||
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
||||||
|
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
|
||||||
|
|
||||||
def test_all_files_tracked_in_manifest(self, tmp_path):
|
def test_all_files_tracked_in_manifest(self, tmp_path):
|
||||||
i = get_integration("generic")
|
i = get_integration("generic")
|
||||||
|
|||||||
Reference in New Issue
Block a user