Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 36 additions & 18 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -723,19 +723,25 @@ 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*.

Copies ``.specify/scripts/`` and ``.specify/templates/`` from the
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()
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -2072,21 +2082,23 @@ 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)

manifest = IntegrationManifest(
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,
Expand Down Expand Up @@ -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)

Expand All @@ -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,
Expand Down Expand Up @@ -2465,19 +2480,22 @@ 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)

# Phase 1: Install new files (overwrites existing; old-only files remain)
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,
Expand Down
54 changes: 48 additions & 6 deletions src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 -->"
Expand All @@ -96,6 +99,18 @@ def options(cls) -> list[IntegrationOption]:
"""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,
Expand All @@ -122,11 +137,12 @@ def build_command_invocation(self, command_name: str, args: str = "") -> str:
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:
Expand Down Expand Up @@ -597,13 +613,32 @@ def remove_context_section(self, project_root: Path) -> bool:

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,
Comment thread
mnriem marked this conversation as resolved.
)

@staticmethod
def process_template(
content: str,
agent_name: str,
script_type: str,
arg_placeholder: str = "$ARGUMENTS",
context_file: str = "",
invoke_separator: str = ".",
) -> str:
"""Process a raw command template into agent-ready content.

Expand All @@ -615,6 +650,7 @@ def process_template(
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 = ""
Expand Down Expand Up @@ -684,6 +720,9 @@ def process_template(

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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1311,10 +1352,10 @@ def skills_dest(self, project_root: Path) -> Path:
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
Expand Down Expand Up @@ -1395,6 +1436,7 @@ def setup(
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
Expand Down
22 changes: 16 additions & 6 deletions src/specify_cli/integrations/copilot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down Expand Up @@ -145,9 +155,9 @@ def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""
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
Comment thread
mnriem marked this conversation as resolved.
Expand Down Expand Up @@ -175,8 +185,8 @@ def dispatch_command(
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
Expand All @@ -189,7 +199,7 @@ def dispatch_command(
)

if skills_mode:
prompt = f"/speckit-{stem}"
prompt = "/speckit-" + stem.replace(".", "-")
if args:
prompt = f"{prompt} {args}"
else:
Expand Down
4 changes: 2 additions & 2 deletions templates/checklist-template.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
**Created**: [DATE]
**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.

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
- Feature requirements from spec.md
- Technical context from plan.md
Expand Down
8 changes: 4 additions & 4 deletions templates/commands/analyze.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,13 @@ You **MUST** consider the user input before proceeding (if not empty).

## 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

**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

Expand Down Expand Up @@ -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:

- 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
- 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

Expand Down
2 changes: 1 addition & 1 deletion templates/commands/checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ You **MUST** consider the user input before proceeding (if not empty).
- Actor/timing
- 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`)
- Simple, memorable filenames that indicate checklist purpose
Expand Down
8 changes: 4 additions & 4 deletions templates/commands/clarify.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,15 @@ 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.

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:

1. Run `{SCRIPT}` from repo root **once** (combined `--json --paths-only` mode / `-Json -PathsOnly`). Parse minimal JSON payload fields:
- `FEATURE_DIR`
- `FEATURE_SPEC`
- (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").

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).
Expand Down Expand Up @@ -202,13 +202,13 @@ Execution steps:
- Path to updated spec.
- 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).
- 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.

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 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).
- Avoid speculative tech stack questions unless the absence blocks functional clarity.
- Respect user early termination signals ("stop", "done", "proceed").
Expand Down
Loading
Loading