diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 77611128b5..1c3e63ec03 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -723,6 +723,7 @@ def _install_shared_infra( script_type: str, tracker: StepTracker | None = None, force: bool = False, + invoke_separator: str = ".", ) -> bool: """Install shared infrastructure files into *project_path*. @@ -730,12 +731,17 @@ def _install_shared_infra( bundled core_pack or source checkout. Tracks all installed files in ``speckit.manifest.json``. + Page templates are processed to resolve ``__SPECKIT_COMMAND___`` + placeholders using *invoke_separator* (``"."`` for markdown agents, + ``"-"`` for skills agents). + When *force* is ``True``, existing files are overwritten with the latest bundled versions. When ``False`` (default), only missing files are added and existing ones are skipped. Returns ``True`` on success. """ + from .integrations.base import IntegrationBase from .integrations.manifest import IntegrationManifest core = _locate_core_pack() @@ -786,7 +792,11 @@ def _install_shared_infra( if dst.exists() and not force: skipped_files.append(str(dst.relative_to(project_path))) else: - shutil.copy2(f, dst) + content = f.read_text(encoding="utf-8") + content = IntegrationBase.resolve_command_refs( + content, invoke_separator + ) + dst.write_text(content, encoding="utf-8") rel = dst.relative_to(project_path).as_posix() manifest.record_existing(rel) @@ -1295,7 +1305,7 @@ def init( # Install shared infrastructure (scripts, templates) tracker.start("shared-infra") - _install_shared_infra(project_path, selected_script, tracker=tracker, force=force) + _install_shared_infra(project_path, selected_script, tracker=tracker, force=force, invoke_separator=resolved_integration.effective_invoke_separator(integration_parsed_options)) tracker.complete("shared-infra", f"scripts ({selected_script}) + templates") ensure_constitution_from_template(project_path, tracker=tracker) @@ -2072,9 +2082,16 @@ def integration_install( selected_script = _resolve_script_type(project_root, script) + # Build parsed options from --integration-options so the integration + # can determine its effective invoke separator before shared infra + # is installed. + parsed_options: dict[str, Any] | None = None + if integration_options: + parsed_options = _parse_integration_options(integration, integration_options) + # Ensure shared infrastructure is present (safe to run unconditionally; # _install_shared_infra merges missing files without overwriting). - _install_shared_infra(project_root, selected_script) + _install_shared_infra(project_root, selected_script, invoke_separator=integration.effective_invoke_separator(parsed_options)) if os.name != "nt": ensure_executable_scripts(project_root) @@ -2082,11 +2099,6 @@ def integration_install( integration.key, project_root, version=get_speckit_version() ) - # Build parsed options from --integration-options - parsed_options: dict[str, Any] | None = None - if integration_options: - parsed_options = _parse_integration_options(integration, integration_options) - try: integration.setup( project_root, manifest, @@ -2356,9 +2368,16 @@ def integration_switch( opts.pop("context_file", None) save_init_options(project_root, opts) + # Build parsed options from --integration-options so the integration + # can determine its effective invoke separator before shared infra + # is installed. + parsed_options: dict[str, Any] | None = None + if integration_options: + parsed_options = _parse_integration_options(target_integration, integration_options) + # Ensure shared infrastructure is present (safe to run unconditionally; # _install_shared_infra merges missing files without overwriting). - _install_shared_infra(project_root, selected_script) + _install_shared_infra(project_root, selected_script, invoke_separator=target_integration.effective_invoke_separator(parsed_options)) if os.name != "nt": ensure_executable_scripts(project_root) @@ -2368,10 +2387,6 @@ def integration_switch( target_integration.key, project_root, version=get_speckit_version() ) - parsed_options: dict[str, Any] | None = None - if integration_options: - parsed_options = _parse_integration_options(target_integration, integration_options) - try: target_integration.setup( project_root, manifest, @@ -2465,8 +2480,15 @@ def integration_upgrade( selected_script = _resolve_script_type(project_root, script) + # Build parsed options from --integration-options so the integration + # can determine its effective invoke separator before shared infra + # is installed. + parsed_options: dict[str, Any] | None = None + if integration_options: + parsed_options = _parse_integration_options(integration, integration_options) + # Ensure shared infrastructure is up to date; --force overwrites existing files. - _install_shared_infra(project_root, selected_script, force=force) + _install_shared_infra(project_root, selected_script, force=force, invoke_separator=integration.effective_invoke_separator(parsed_options)) if os.name != "nt": ensure_executable_scripts(project_root) @@ -2474,10 +2496,6 @@ def integration_upgrade( console.print(f"Upgrading integration: [cyan]{key}[/cyan]") new_manifest = IntegrationManifest(key, project_root, version=get_speckit_version()) - parsed_options: dict[str, Any] | None = None - if integration_options: - parsed_options = _parse_integration_options(integration, integration_options) - try: integration.setup( project_root, diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index a3d8a42aa2..f3b74b0c05 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -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 = "" @@ -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, @@ -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: @@ -597,6 +613,24 @@ def remove_context_section(self, project_root: Path) -> bool: return True + @staticmethod + def resolve_command_refs(content: str, separator: str = ".") -> str: + """Replace ``__SPECKIT_COMMAND___`` placeholders with invocations. + + Each placeholder encodes a command name in upper-case with + underscores (e.g. ``__SPECKIT_COMMAND_PLAN__``, + ``__SPECKIT_COMMAND_GIT_COMMIT__``). The replacement uses + *separator* to join the segments: + + * ``separator="."`` → ``/speckit.plan``, ``/speckit.git.commit`` + * ``separator="-"`` → ``/speckit-plan``, ``/speckit-git-commit`` + """ + return re.sub( + r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", + lambda m: "/speckit" + separator + m.group(1).lower().replace("_", separator), + content, + ) + @staticmethod def process_template( content: str, @@ -604,6 +638,7 @@ def process_template( script_type: str, arg_placeholder: str = "$ARGUMENTS", context_file: str = "", + invoke_separator: str = ".", ) -> str: """Process a raw command template into agent-ready content. @@ -615,6 +650,7 @@ 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___`` with invocation strings """ # 1. Extract script command from frontmatter script_command = "" @@ -684,6 +720,9 @@ def process_template( content = CommandRegistrar.rewrite_project_relative_paths(content) + # 8. Replace __SPECKIT_COMMAND___ with invocation strings + content = IntegrationBase.resolve_command_refs(content, invoke_separator) + return content def setup( @@ -1274,6 +1313,8 @@ class SkillsIntegration(IntegrationBase): ``speckit-/SKILL.md`` file with skills-oriented frontmatter. """ + invoke_separator = "-" + def build_exec_args( self, prompt: str, @@ -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-`` (hyphenated directory name).""" stem = command_name - if "." in stem: - stem = stem.rsplit(".", 1)[-1] + if stem.startswith("speckit."): + stem = stem[len("speckit."):] - invocation = f"/speckit-{stem}" + invocation = "/speckit-" + stem.replace(".", "-") if args: invocation = f"{invocation} {args}" return invocation @@ -1395,6 +1436,7 @@ 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 diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 5c4d0e5410..c7456ce7f0 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -103,6 +103,16 @@ class CopilotIntegration(IntegrationBase): # Mutable flag set by setup() — indicates the active scaffolding mode. _skills_mode: bool = False + def effective_invoke_separator( + self, parsed_options: dict[str, Any] | None = None + ) -> str: + """Return ``"-"`` when skills mode is requested, ``"."`` otherwise.""" + if parsed_options and parsed_options.get("skills"): + return "-" + if self._skills_mode: + return "-" + return self.invoke_separator + @classmethod def options(cls) -> list[IntegrationOption]: return [ @@ -145,9 +155,9 @@ 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 @@ -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 @@ -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: diff --git a/templates/checklist-template.md b/templates/checklist-template.md index 806657da09..9752c130ec 100644 --- a/templates/checklist-template.md +++ b/templates/checklist-template.md @@ -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.