diff --git a/AGENTS.md b/AGENTS.md index ca3f2a537e..dc076bb991 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,6 +130,7 @@ class CodexIntegration(SkillsIntegration): "extension": "/SKILL.md", } context_file = "AGENTS.md" + user_command_prefix = "$" @classmethod def options(cls) -> list[IntegrationOption]: @@ -223,6 +224,7 @@ The base classes handle most work automatically. Override only when the agent de | Override | When to use | Example | |---|---|---| | `command_filename(template_name)` | Custom file naming or extension | Copilot → `speckit.{name}.agent.md` | +| `user_command_prefix` / `effective_user_command_prefix()` | User-facing command hints need a non-standard prefix | Codex → `$speckit-plan`, Kimi → `/skill:speckit-plan` | | `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag, Copilot → `--skills` flag | | `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` (default) or `speckit-/SKILL.md` (skills mode) | | `teardown()` | Custom uninstall logic | Rarely needed; base handles manifest-tracked files | @@ -357,6 +359,7 @@ via `--integration-options="--skills"`. When enabled: - No `.vscode/settings.json` merge - `post_process_skill_content()` injects a `mode: speckit.` frontmatter field - `build_command_invocation()` returns `/speckit-` instead of bare args +- `build_user_command_invocation()` returns `/speckit-` for generated hints The two modes are mutually exclusive — a project uses one or the other: diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 99442e7033..ba03adc934 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -40,6 +40,7 @@ from rich.align import Align from rich.table import Table from .integration_runtime import ( + command_prefix_for_integration as _command_prefix_for_integration, invoke_separator_for_integration as _invoke_separator_for_integration, resolve_integration_options as _resolve_integration_options_impl, with_integration_setting as _with_integration_setting, @@ -131,6 +132,7 @@ def _refresh_shared_templates( project_path: Path, *, invoke_separator: str, + command_prefix: str = "/", force: bool = False, ) -> None: """Refresh default-sensitive shared templates without touching scripts.""" @@ -141,6 +143,7 @@ def _refresh_shared_templates( repo_root=_repo_root(), console=console, invoke_separator=invoke_separator, + command_prefix=command_prefix, force=force, ) @@ -151,6 +154,7 @@ def _install_shared_infra( tracker: StepTracker | None = None, force: bool = False, invoke_separator: str = ".", + command_prefix: str = "/", refresh_managed: bool = False, refresh_hint: str | None = None, ) -> bool: @@ -163,7 +167,8 @@ def _install_shared_infra( Shared scripts and page templates are processed to resolve ``__SPECKIT_COMMAND___`` placeholders using *invoke_separator* - (``"."`` for markdown agents, ``"-"`` for skills agents). + and *command_prefix* (for example ``"."``/``"/"`` for markdown agents + or ``"-"``/``"$"`` for Codex skills). Overwrite policy: @@ -194,6 +199,7 @@ def _install_shared_infra( console=console, force=force, invoke_separator=invoke_separator, + command_prefix=command_prefix, refresh_managed=refresh_managed, refresh_hint=refresh_hint, ) @@ -205,6 +211,7 @@ def _install_shared_infra_or_exit( tracker: StepTracker | None = None, force: bool = False, invoke_separator: str = ".", + command_prefix: str = "/", refresh_managed: bool = False, refresh_hint: str | None = None, ) -> bool: @@ -215,6 +222,7 @@ def _install_shared_infra_or_exit( tracker=tracker, force=force, invoke_separator=invoke_separator, + command_prefix=command_prefix, refresh_managed=refresh_managed, refresh_hint=refresh_hint, ) @@ -846,6 +854,9 @@ def _set_default_integration( invoke_separator=_invoke_separator_for_integration( integration, {"integration_settings": settings}, key, parsed_options ), + command_prefix=_command_prefix_for_integration( + integration, {"integration_settings": settings}, key, parsed_options + ), force=refresh_templates_force, refresh_managed=True, refresh_hint=refresh_hint, @@ -1067,6 +1078,9 @@ def integration_install( invoke_separator=_invoke_separator_for_integration( infra_integration, current, infra_key, infra_parsed ), + command_prefix=_command_prefix_for_integration( + infra_integration, current, infra_key, infra_parsed + ), ) if os.name != "nt": ensure_executable_scripts(project_root) @@ -1583,6 +1597,9 @@ def integration_switch( invoke_separator=_invoke_separator_for_integration( target_integration, current, target, parsed_options ), + command_prefix=_command_prefix_for_integration( + target_integration, current, target, parsed_options + ), refresh_hint=( "To overwrite customizations, re-run with " "[cyan]specify integration switch ... --refresh-shared-infra[/cyan]." @@ -1767,6 +1784,9 @@ def integration_upgrade( invoke_separator=_invoke_separator_for_integration( infra_integration, current, infra_key, infra_parsed ), + command_prefix=_command_prefix_for_integration( + infra_integration, current, infra_key, infra_parsed + ), ) if os.name != "nt": ensure_executable_scripts(project_root) @@ -1799,6 +1819,9 @@ def integration_upgrade( invoke_separator=_invoke_separator_for_integration( integration, {"integration_settings": settings}, key, parsed_options ), + command_prefix=_command_prefix_for_integration( + integration, {"integration_settings": settings}, key, parsed_options + ), force=force, refresh_managed=True, ) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 404a3a0a0e..c31c8226cf 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -34,6 +34,8 @@ def _build_agent_configs() -> dict[str, Any]: # when register_commands() resolves __SPECKIT_COMMAND_*__ tokens. if "invoke_separator" not in config: config["invoke_separator"] = integration.invoke_separator + if "command_prefix" not in config: + config["command_prefix"] = integration.user_command_prefix configs[key] = config return configs @@ -528,7 +530,8 @@ def register_commands( from specify_cli.integrations.base import IntegrationBase # noqa: PLC0415 _sep = agent_config.get("invoke_separator", ".") - body = IntegrationBase.resolve_command_refs(body, _sep) + _prefix = agent_config.get("command_prefix", "/") + body = IntegrationBase.resolve_command_refs(body, _sep, prefix=_prefix) output_name = self._compute_output_name(agent_name, cmd_name, agent_config) diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index e5dc47e98c..a7b7808ec6 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -158,7 +158,10 @@ def init( ensure_executable_scripts, save_init_options, ) - from ..integration_runtime import with_integration_setting as _with_integration_setting + from ..integration_runtime import ( + command_prefix_for_integration as _command_prefix_for_integration, + with_integration_setting as _with_integration_setting, + ) show_banner() ai_deprecation_warning: str | None = None @@ -453,6 +456,12 @@ def init( tracker=tracker, force=force, invoke_separator=resolved_integration.effective_invoke_separator(integration_parsed_options), + command_prefix=_command_prefix_for_integration( + resolved_integration, + {"integration_settings": integration_settings}, + resolved_integration.key, + integration_parsed_options, + ), ) tracker.complete("shared-infra", f"scripts ({selected_script}) + templates") @@ -743,15 +752,10 @@ def init( usage_label = "skills" if native_skill_mode else "slash commands" def _display_cmd(name: str) -> str: - if codex_skill_mode or agy_skill_mode or trae_skill_mode: - return f"$speckit-{name}" - if claude_skill_mode: - return f"/speckit-{name}" - if kimi_skill_mode: - return f"/skill:speckit-{name}" - if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode: - return f"/speckit-{name}" - return f"/speckit.{name}" + return resolved_integration.build_user_command_invocation( + f"speckit.{name}", + parsed_options=integration_parsed_options or None, + ) steps_lines.append(f"{step_num}. Start using {usage_label} with your coding agent:") diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 5a595fbffa..4a536bbd76 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2407,6 +2407,26 @@ def _render_hook_invocation(self, command: Any) -> str: if not command_id: return "" + if command_id.startswith("speckit."): + try: + from .integration_runtime import user_command_invocation_for_integration + from .integration_state import default_integration_key, try_read_integration_json + from .integrations import get_integration + + state, _ = try_read_integration_json(self.project_root) + if state: + key = default_integration_key(state) + integration = get_integration(key) if key else None + if integration: + return user_command_invocation_for_integration( + integration, + state, + key, + command_id, + ) + except Exception: + pass + init_options = self._load_init_options() selected_ai = init_options.get("ai") codex_skill_mode = selected_ai == "codex" and bool(init_options.get("ai_skills")) diff --git a/src/specify_cli/integration_runtime.py b/src/specify_cli/integration_runtime.py index a36dcc672c..06f0c19ab9 100644 --- a/src/specify_cli/integration_runtime.py +++ b/src/specify_cli/integration_runtime.py @@ -64,6 +64,7 @@ def with_integration_setting( current.pop("parsed_options", None) current["invoke_separator"] = integration.effective_invoke_separator(parsed_options) + current["command_prefix"] = integration.effective_user_command_prefix(parsed_options) settings[key] = current return settings @@ -88,3 +89,48 @@ def invoke_separator_for_integration( return integration.effective_invoke_separator(stored_parsed) return integration.effective_invoke_separator(None) + + +def command_prefix_for_integration( + integration: Any, + state: dict[str, Any], + key: str, + parsed_options: dict[str, Any] | None = None, +) -> str: + """Resolve the user-facing command prefix for stored/default state.""" + if parsed_options is not None: + return integration.effective_user_command_prefix(parsed_options) + + setting = integration_setting(state, key) + stored_prefix = setting.get("command_prefix") + if isinstance(stored_prefix, str) and stored_prefix: + return stored_prefix + + stored_parsed = setting.get("parsed_options") + if isinstance(stored_parsed, dict): + return integration.effective_user_command_prefix(stored_parsed) + + return integration.effective_user_command_prefix(None) + + +def user_command_invocation_for_integration( + integration: Any, + state: dict[str, Any], + key: str, + command_name: str, + args: str = "", + parsed_options: dict[str, Any] | None = None, +) -> str: + """Build a copy-pasteable command invocation for integration guidance.""" + options = parsed_options + if options is None: + setting = integration_setting(state, key) + stored_parsed = setting.get("parsed_options") + if isinstance(stored_parsed, dict): + options = stored_parsed + + return integration.build_user_command_invocation( + command_name, + args, + parsed_options=options, + ) diff --git a/src/specify_cli/integration_state.py b/src/specify_cli/integration_state.py index 2b18324351..a9fcd176b3 100644 --- a/src/specify_cli/integration_state.py +++ b/src/specify_cli/integration_state.py @@ -122,6 +122,10 @@ def normalize_integration_settings(settings: Any) -> dict[str, dict[str, Any]]: if isinstance(invoke_separator, str) and invoke_separator.strip(): clean["invoke_separator"] = invoke_separator.strip() + command_prefix = value.get("command_prefix") + if isinstance(command_prefix, str) and command_prefix.strip(): + clean["command_prefix"] = command_prefix.strip() + if clean: normalized[key.strip()] = clean diff --git a/src/specify_cli/integrations/agy/__init__.py b/src/specify_cli/integrations/agy/__init__.py index 6ed69e1e0e..93bcc8a2e1 100644 --- a/src/specify_cli/integrations/agy/__init__.py +++ b/src/specify_cli/integrations/agy/__init__.py @@ -16,12 +16,12 @@ # Note injected into hook sections so agy maps dot-notation command # names (from extensions.yml) to the hyphenated skill names it uses. -# Without this, agy emits ``/speckit.git.commit`` (which does not -# resolve) instead of ``/speckit-git-commit``. +# Without this, agy may emit ``/speckit.git.commit`` (which does not +# resolve) instead of ``$speckit-git-commit``. _HOOK_COMMAND_NOTE = ( - "- When constructing slash commands from hook command names, " + "- When constructing command invocations from hook command names, " "replace dots (`.`) with hyphens (`-`). " - "For example, `speckit.git.commit` → `/speckit-git-commit`.\n" + "For example, `speckit.git.commit` → `$speckit-git-commit`.\n" ) @@ -43,21 +43,36 @@ class AgyIntegration(SkillsIntegration): "extension": "/SKILL.md", } context_file = "AGENTS.md" + user_command_prefix = "$" - @staticmethod - def _inject_hook_command_note(content: str) -> str: + @classmethod + def _inject_hook_command_note(cls, content: str) -> str: """Insert a dot-to-hyphen note before each hook output instruction. Targets the line ``- For each executable hook, output the following`` and inserts the note on the line before it, matching its indentation. Skips if the note is already present. """ - if "replace dots" in content: - return content + note = cls._hook_command_note().rstrip("\n") def repl(m: re.Match[str]) -> str: indent = m.group(1) instruction = m.group(2) + previous_lines = content[:m.start()].splitlines() + previous_line = previous_lines[-1] if previous_lines else "" + if previous_line == indent + note or ( + previous_line.startswith(indent) + and ( + "{command_invocation}" in previous_line + or cls.format_user_command_invocation( + "speckit.git.commit", + prefix=cls.user_command_prefix, + separator=cls.invoke_separator, + ) + in previous_line + ) + ): + return m.group(0) # ``eol`` is empty when the regex matched via ``$`` because the # instruction was the final line of a file with no trailing # newline. Default to ``\n`` so the note never collapses onto @@ -65,7 +80,7 @@ def repl(m: re.Match[str]) -> str: eol = m.group(3) or "\n" return ( indent - + _HOOK_COMMAND_NOTE.rstrip("\n") + + note + eol + indent + instruction @@ -73,14 +88,14 @@ def repl(m: re.Match[str]) -> str: ) return re.sub( - r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)", + r"(?m)^([ \t]*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)", repl, content, ) def post_process_skill_content(self, content: str) -> str: """Inject the dot-to-hyphen hook command note.""" - return self._inject_hook_command_note(content) + return type(self)._inject_hook_command_note(content) def build_exec_args( self, diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 4742a37cab..afb2b1d0ba 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -21,7 +21,7 @@ from abc import ABC from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Callable import yaml @@ -29,7 +29,7 @@ from .manifest import IntegrationManifest _HOOK_COMMAND_NOTE = ( - "- When constructing slash commands from hook command names, " + "- When constructing command invocations from hook command names, " "replace dots (`.`) with hyphens (`-`). " "For example, `speckit.git.commit` → `/speckit-git-commit`.\n" ) @@ -98,6 +98,15 @@ class IntegrationBase(ABC): invoke_separator: str = "." """Separator used in slash-command invocations (``"."`` → ``/speckit.plan``).""" + user_command_prefix: str = "/" + """Prefix used in user-facing command hints (``"/"`` → ``/speckit.plan``). + + This is intentionally separate from ``build_command_invocation()``, which + remains the native dispatch string passed to an agent CLI. Integrations + whose copy-pasteable command syntax differs from native dispatch should + override this attribute or ``effective_user_command_prefix()``. + """ + multi_install_safe: bool = False """Whether this integration is declared safe to install alongside others. @@ -130,6 +139,60 @@ def effective_invoke_separator( """ return self.invoke_separator + def effective_user_command_prefix( + self, parsed_options: dict[str, Any] | None = None + ) -> str: + """Return the user-facing command prefix for the given options. + + Subclasses whose visible command prefix depends on runtime options + should override this method. The default implementation ignores + *parsed_options* and returns ``user_command_prefix``. + """ + return self.user_command_prefix + + @staticmethod + def _command_stem(command_name: str) -> str: + """Normalize a Spec Kit command name to the stem after ``speckit.``.""" + stem = command_name + if stem.startswith("speckit."): + stem = stem[len("speckit."):] + return stem + + @staticmethod + def format_user_command_invocation( + command_name: str, + args: str = "", + *, + prefix: str = "/", + separator: str = ".", + ) -> str: + """Format a copy-pasteable command hint for users. + + ``command_name`` may be a full dotted name (``speckit.git.commit``) + or a bare stem (``git.commit``). ``prefix`` and ``separator`` are the + integration-owned visible syntax, e.g. ``("$", "-")`` for Codex + skills or ``("/", ".")`` for standard slash commands. + """ + stem = IntegrationBase._command_stem(command_name) + invocation = f"{prefix}speckit{separator}{stem.replace('.', separator)}" + if args: + invocation = f"{invocation} {args}" + return invocation + + def build_user_command_invocation( + self, + command_name: str, + args: str = "", + parsed_options: dict[str, Any] | None = None, + ) -> str: + """Build the copy-pasteable invocation shown in generated guidance.""" + return self.format_user_command_invocation( + command_name, + args, + prefix=self.effective_user_command_prefix(parsed_options), + separator=self.effective_invoke_separator(parsed_options), + ) + def build_exec_args( self, prompt: str, @@ -218,9 +281,7 @@ def build_command_invocation(self, command_name: str, args: str = "") -> str: ``"speckit.specify"``, an extension command like ``"speckit.git.commit"``, or a bare stem like ``"specify"``. """ - stem = command_name - if stem.startswith("speckit."): - stem = stem[len("speckit."):] + stem = self._command_stem(command_name) invocation = f"/speckit.{stem}" if args: @@ -805,20 +866,40 @@ def remove_context_section(self, project_root: Path) -> bool: return True @staticmethod - def resolve_command_refs(content: str, separator: str = ".") -> str: + def resolve_command_refs( + content: str, + separator: str = ".", + *, + prefix: str = "/", + command_builder: Callable[[str], str] | None = None, + ) -> 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: + ``__SPECKIT_COMMAND_GIT_COMMIT__``). The replacement uses + *prefix* plus *separator* to render copy-pasteable user guidance: * ``separator="."`` → ``/speckit.plan``, ``/speckit.git.commit`` * ``separator="-"`` → ``/speckit-plan``, ``/speckit-git-commit`` + + Callers that need option-sensitive formatting may pass + ``command_builder``; it receives the full dotted command name such as + ``"speckit.git.commit"``. """ + def repl(m: re.Match[str]) -> str: + command_name = "speckit." + m.group(1).lower().replace("_", ".") + if command_builder: + return command_builder(command_name) + return IntegrationBase.format_user_command_invocation( + command_name, + prefix=prefix, + separator=separator, + ) + return re.sub( r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", - lambda m: "/speckit" + separator + m.group(1).lower().replace("_", separator), + repl, content, ) @@ -830,6 +911,8 @@ def process_template( arg_placeholder: str = "$ARGUMENTS", context_file: str = "", invoke_separator: str = ".", + command_prefix: str = "/", + command_invocation_builder: Callable[[str], str] | None = None, ) -> str: """Process a raw command template into agent-ready content. @@ -912,7 +995,12 @@ 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) + content = IntegrationBase.resolve_command_refs( + content, + invoke_separator, + prefix=command_prefix, + command_builder=command_invocation_builder, + ) return content @@ -1079,6 +1167,8 @@ def setup( processed = self.process_template( raw, self.key, script_type, arg_placeholder, context_file=self.context_file or "", + invoke_separator=self.effective_invoke_separator(parsed_options), + command_prefix=self.effective_user_command_prefix(parsed_options), ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( @@ -1285,6 +1375,8 @@ def setup( processed = self.process_template( raw, self.key, script_type, arg_placeholder, context_file=self.context_file or "", + invoke_separator=self.effective_invoke_separator(parsed_options), + command_prefix=self.effective_user_command_prefix(parsed_options), ) _, body = self._split_frontmatter(processed) toml_content = self._render_toml(description, body) @@ -1489,6 +1581,8 @@ def setup( processed = self.process_template( raw, self.key, script_type, arg_placeholder, context_file=self.context_file or "", + invoke_separator=self.effective_invoke_separator(parsed_options), + command_prefix=self.effective_user_command_prefix(parsed_options), ) _, body = self._split_frontmatter(processed) yaml_content = self._render_yaml( @@ -1565,17 +1659,29 @@ 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 stem.startswith("speckit."): - stem = stem[len("speckit."):] + stem = self._command_stem(command_name) invocation = "/speckit-" + stem.replace(".", "-") if args: invocation = f"{invocation} {args}" return invocation - @staticmethod - def _inject_hook_command_note(content: str) -> str: + @classmethod + def _hook_command_note(cls) -> str: + """Return the integration-specific hook command example note.""" + example = cls.format_user_command_invocation( + "speckit.git.commit", + prefix=cls.user_command_prefix, + separator=cls.invoke_separator, + ) + return ( + "- When constructing command invocations from hook command names, " + "replace dots (`.`) with hyphens (`-`). " + f"For example, `speckit.git.commit` → `{example}`.\n" + ) + + @classmethod + def _inject_hook_command_note(cls, content: str) -> str: """Insert a dot-to-hyphen note before each hook output instruction. Targets the line ``- For each executable hook, output the following`` @@ -1583,13 +1689,25 @@ def _inject_hook_command_note(content: str) -> str: Skips individual instructions that already have the note immediately above them. """ - note = _HOOK_COMMAND_NOTE.rstrip("\n") + note = cls._hook_command_note().rstrip("\n") def repl(m: re.Match[str]) -> str: indent = m.group(1) instruction = m.group(2) previous_lines = content[:m.start()].splitlines() - if previous_lines and previous_lines[-1] == indent + note: + previous_line = previous_lines[-1] if previous_lines else "" + if previous_line == indent + note or ( + previous_line.startswith(indent) + and ( + "{command_invocation}" in previous_line + or cls.format_user_command_invocation( + "speckit.git.commit", + prefix=cls.user_command_prefix, + separator=cls.invoke_separator, + ) + in previous_line + ) + ): return m.group(0) # ``eol`` is empty when the regex matched via ``$`` because the # instruction was the final line of a file with no trailing @@ -1620,7 +1738,7 @@ def post_process_skill_content(self, content: str) -> str: guidance for converting dotted hook command names to hyphenated slash commands. Subclasses may override — see ``ClaudeIntegration``. """ - return self._inject_hook_command_note(content) + return type(self)._inject_hook_command_note(content) def setup( self, @@ -1687,7 +1805,8 @@ 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, + invoke_separator=self.effective_invoke_separator(parsed_options), + command_prefix=self.effective_user_command_prefix(parsed_options), ) # 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/codex/__init__.py b/src/specify_cli/integrations/codex/__init__.py index 1f7dbc601f..b0c74b7e23 100644 --- a/src/specify_cli/integrations/codex/__init__.py +++ b/src/specify_cli/integrations/codex/__init__.py @@ -28,6 +28,7 @@ class CodexIntegration(SkillsIntegration): } context_file = "AGENTS.md" multi_install_safe = True + user_command_prefix = "$" def build_exec_args( self, diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 0d32edff72..5a74cd5013 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -395,6 +395,8 @@ def _setup_default( processed = self.process_template( raw, self.key, script_type, arg_placeholder, context_file=self.context_file or "", + invoke_separator=self.effective_invoke_separator(parsed_options), + command_prefix=self.effective_user_command_prefix(parsed_options), ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( diff --git a/src/specify_cli/integrations/forge/__init__.py b/src/specify_cli/integrations/forge/__init__.py index 47a90687dc..88a651c2a6 100644 --- a/src/specify_cli/integrations/forge/__init__.py +++ b/src/specify_cli/integrations/forge/__init__.py @@ -135,7 +135,8 @@ def setup( processed = self.process_template( raw, self.key, script_type, arg_placeholder, context_file=self.context_file or "", - invoke_separator=self.invoke_separator, + invoke_separator=self.effective_invoke_separator(parsed_options), + command_prefix=self.effective_user_command_prefix(parsed_options), ) # FORGE-SPECIFIC: Ensure any remaining $ARGUMENTS placeholders are diff --git a/src/specify_cli/integrations/generic/__init__.py b/src/specify_cli/integrations/generic/__init__.py index fdaee4ed04..f3d40cd2ee 100644 --- a/src/specify_cli/integrations/generic/__init__.py +++ b/src/specify_cli/integrations/generic/__init__.py @@ -125,6 +125,8 @@ def setup( processed = self.process_template( raw, self.key, script_type, arg_placeholder, context_file=self.context_file or "", + invoke_separator=self.effective_invoke_separator(parsed_options), + command_prefix=self.effective_user_command_prefix(parsed_options), ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( diff --git a/src/specify_cli/integrations/hermes/__init__.py b/src/specify_cli/integrations/hermes/__init__.py index 44556590c9..4aeca95b2a 100644 --- a/src/specify_cli/integrations/hermes/__init__.py +++ b/src/specify_cli/integrations/hermes/__init__.py @@ -141,7 +141,8 @@ def setup( script_type, arg_placeholder, context_file=self.context_file or "", - invoke_separator=self.invoke_separator, + invoke_separator=self.effective_invoke_separator(parsed_options), + command_prefix=self.effective_user_command_prefix(parsed_options), ) # Strip the processed frontmatter — we rebuild it for skills. if processed_body.startswith("---"): diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py index 3b257768e2..f86a184ed8 100644 --- a/src/specify_cli/integrations/kimi/__init__.py +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -37,6 +37,7 @@ class KimiIntegration(SkillsIntegration): } context_file = "KIMI.md" multi_install_safe = True + user_command_prefix = "/skill:" @classmethod def options(cls) -> list[IntegrationOption]: diff --git a/src/specify_cli/integrations/trae/__init__.py b/src/specify_cli/integrations/trae/__init__.py index 4556487d07..0a10a771da 100644 --- a/src/specify_cli/integrations/trae/__init__.py +++ b/src/specify_cli/integrations/trae/__init__.py @@ -28,6 +28,7 @@ class TraeIntegration(SkillsIntegration): } context_file = ".trae/rules/project_rules.md" multi_install_safe = True + user_command_prefix = "$" @classmethod def options(cls) -> list[IntegrationOption]: diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index 5219880741..0904b1046e 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -1144,16 +1144,20 @@ def _resolve_skill_command_refs( ) -> str: """Render ``__SPECKIT_COMMAND_*__`` tokens in a skill body as invocations. - Looks up the agent's invoke separator and rewrites each + Looks up the agent's invoke separator and command prefix, then rewrites each ``__SPECKIT_COMMAND___`` placeholder into the matching - slash-command invocation — ``/speckit-`` for a ``-`` separator, - ``/speckit.`` for ``.`` — the same rendering the command layer - applies via ``CommandRegistrar.register_commands()``. + user-facing invocation — for example ``$speckit-`` for Codex + or ``/speckit.`` for standard markdown commands. This matches + the rendering the command layer applies via + ``CommandRegistrar.register_commands()``. """ separator = registrar.AGENT_CONFIGS.get(selected_ai, {}).get( "invoke_separator", "." ) - return IntegrationBase.resolve_command_refs(body, separator) + prefix = registrar.AGENT_CONFIGS.get(selected_ai, {}).get( + "command_prefix", "/" + ) + return IntegrationBase.resolve_command_refs(body, separator, prefix=prefix) def _build_extension_skill_restore_index(self) -> Dict[str, Dict[str, Any]]: """Index extension-backed skill restore data by skill directory name.""" diff --git a/src/specify_cli/shared_infra.py b/src/specify_cli/shared_infra.py index f57f5722e3..26dbe5cb77 100644 --- a/src/specify_cli/shared_infra.py +++ b/src/specify_cli/shared_infra.py @@ -194,6 +194,28 @@ def _write_shared_bytes( temp_path.unlink() +def _escape_command_prefix_for_script( + content: str, + script_type: str, + command_prefix: str, +) -> str: + """Escape dollar-prefixed command hints inside script output strings.""" + if command_prefix != "$": + return content + + escaped_lines: list[str] = [] + for line in content.splitlines(keepends=True): + stripped = line.lstrip() + if script_type == "sh" and stripped.startswith("echo "): + line = line.replace("$speckit", r"\$speckit") + elif script_type == "ps" and ( + "Write-Output " in stripped or "WriteLine(" in stripped + ): + line = line.replace("$speckit", "`$speckit") + escaped_lines.append(line) + return "".join(escaped_lines) + + def refresh_shared_templates( project_path: Path, *, @@ -202,6 +224,7 @@ def refresh_shared_templates( repo_root: Path, console: Any, invoke_separator: str, + command_prefix: str = "/", force: bool = False, ) -> None: """Refresh default-sensitive shared templates without touching scripts.""" @@ -230,7 +253,9 @@ def refresh_shared_templates( continue content = src.read_text(encoding="utf-8") - content = IntegrationBase.resolve_command_refs(content, invoke_separator) + content = IntegrationBase.resolve_command_refs( + content, invoke_separator, prefix=command_prefix + ) planned_updates.append((dst, rel, content)) for dst, rel, content in planned_updates: @@ -257,6 +282,7 @@ def install_shared_infra( console: Any, force: bool = False, invoke_separator: str = ".", + command_prefix: str = "/", refresh_managed: bool = False, refresh_hint: str | None = None, ) -> bool: @@ -370,7 +396,12 @@ def _ensure_or_bucket_dir(directory: Path) -> bool: if not _ensure_or_bucket_dir(dst_path.parent): continue content = src_path.read_text(encoding="utf-8") - content = IntegrationBase.resolve_command_refs(content, invoke_separator) + content = IntegrationBase.resolve_command_refs( + content, invoke_separator, prefix=command_prefix + ) + content = _escape_command_prefix_for_script( + content, script_type, command_prefix + ) planned_copies.append( ( dst_path, @@ -401,7 +432,9 @@ def _ensure_or_bucket_dir(directory: Path) -> bool: continue content = src.read_text(encoding="utf-8") - content = IntegrationBase.resolve_command_refs(content, invoke_separator) + content = IntegrationBase.resolve_command_refs( + content, invoke_separator, prefix=command_prefix + ) planned_templates.append((dst, rel, content)) for dst_path, rel, content, mode in planned_copies: diff --git a/templates/commands/analyze.md b/templates/commands/analyze.md index 5b521cf2a4..56cfbdb046 100644 --- a/templates/commands/analyze.md +++ b/templates/commands/analyze.md @@ -23,24 +23,25 @@ You **MUST** consider the user input before proceeding (if not empty). - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- Set `{command_invocation}` to the user-facing invocation for the hook command; when the active command syntax requires it, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` becomes `__SPECKIT_COMMAND_GIT_COMMIT__`. - For each executable hook, output the following based on its `optional` flag: - **Optional hook** (`optional: true`): ``` ## Extension Hooks **Optional Pre-Hook**: {extension} - Command: `/{command}` + Command: `{command_invocation}` Description: {description} Prompt: {prompt} - To execute: `/{command}` + To execute: `{command_invocation}` ``` - **Mandatory hook** (`optional: false`): ``` ## Extension Hooks **Automatic Pre-Hook**: {extension} - Executing: `/{command}` + Executing: `{command_invocation}` EXECUTE_COMMAND: {command} Wait for the result of the hook command before proceeding to the Goal. @@ -208,24 +209,25 @@ After reporting, check if `.specify/extensions.yml` exists in the project root. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- Set `{command_invocation}` to the user-facing invocation for the hook command; when the active command syntax requires it, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` becomes `__SPECKIT_COMMAND_GIT_COMMIT__`. - For each executable hook, output the following based on its `optional` flag: - **Optional hook** (`optional: true`): ``` ## Extension Hooks **Optional Hook**: {extension} - Command: `/{command}` + Command: `{command_invocation}` Description: {description} Prompt: {prompt} - To execute: `/{command}` + To execute: `{command_invocation}` ``` - **Mandatory hook** (`optional: false`): ``` ## Extension Hooks **Automatic Hook**: {extension} - Executing: `/{command}` + Executing: `{command_invocation}` EXECUTE_COMMAND: {command} ``` - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently diff --git a/templates/commands/checklist.md b/templates/commands/checklist.md index 2e1b1040af..85cf6014b0 100644 --- a/templates/commands/checklist.md +++ b/templates/commands/checklist.md @@ -44,24 +44,25 @@ You **MUST** consider the user input before proceeding (if not empty). - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- Set `{command_invocation}` to the user-facing invocation for the hook command; when the active command syntax requires it, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` becomes `__SPECKIT_COMMAND_GIT_COMMIT__`. - For each executable hook, output the following based on its `optional` flag: - **Optional hook** (`optional: true`): ``` ## Extension Hooks **Optional Pre-Hook**: {extension} - Command: `/{command}` + Command: `{command_invocation}` Description: {description} Prompt: {prompt} - To execute: `/{command}` + To execute: `{command_invocation}` ``` - **Mandatory hook** (`optional: false`): ``` ## Extension Hooks **Automatic Pre-Hook**: {extension} - Executing: `/{command}` + Executing: `{command_invocation}` EXECUTE_COMMAND: {command} Wait for the result of the hook command before proceeding to the Execution Steps. @@ -343,24 +344,25 @@ Check if `.specify/extensions.yml` exists in the project root. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- Set `{command_invocation}` to the user-facing invocation for the hook command; when the active command syntax requires it, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` becomes `__SPECKIT_COMMAND_GIT_COMMIT__`. - For each executable hook, output the following based on its `optional` flag: - **Optional hook** (`optional: true`): ``` ## Extension Hooks **Optional Hook**: {extension} - Command: `/{command}` + Command: `{command_invocation}` Description: {description} Prompt: {prompt} - To execute: `/{command}` + To execute: `{command_invocation}` ``` - **Mandatory hook** (`optional: false`): ``` ## Extension Hooks **Automatic Hook**: {extension} - Executing: `/{command}` + Executing: `{command_invocation}` EXECUTE_COMMAND: {command} ``` - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently diff --git a/templates/commands/clarify.md b/templates/commands/clarify.md index a83d52f026..f92c43ccf1 100644 --- a/templates/commands/clarify.md +++ b/templates/commands/clarify.md @@ -27,24 +27,25 @@ You **MUST** consider the user input before proceeding (if not empty). - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- Set `{command_invocation}` to the user-facing invocation for the hook command; when the active command syntax requires it, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` becomes `__SPECKIT_COMMAND_GIT_COMMIT__`. - For each executable hook, output the following based on its `optional` flag: - **Optional hook** (`optional: true`): ``` ## Extension Hooks **Optional Pre-Hook**: {extension} - Command: `/{command}` + Command: `{command_invocation}` Description: {description} Prompt: {prompt} - To execute: `/{command}` + To execute: `{command_invocation}` ``` - **Mandatory hook** (`optional: false`): ``` ## Extension Hooks **Automatic Pre-Hook**: {extension} - Executing: `/{command}` + Executing: `{command_invocation}` EXECUTE_COMMAND: {command} Wait for the result of the hook command before proceeding to the Outline. @@ -242,13 +243,14 @@ Check if `.specify/extensions.yml` exists in the project root. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- Set `{command_invocation}` to the user-facing invocation for the hook command; when the active command syntax requires it, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` becomes `__SPECKIT_COMMAND_GIT_COMMIT__`. - For each executable hook, output the following based on its `optional` flag: - **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**: ``` ## Extension Hooks **Automatic Hook**: {extension} - Executing: `/{command}` + Executing: `{command_invocation}` EXECUTE_COMMAND: {command} ``` - **Optional hook** (`optional: true`): @@ -256,11 +258,11 @@ Check if `.specify/extensions.yml` exists in the project root. ## Extension Hooks **Optional Hook**: {extension} - Command: `/{command}` + Command: `{command_invocation}` Description: {description} Prompt: {prompt} - To execute: `/{command}` + To execute: `{command_invocation}` ``` ## Completion Report diff --git a/templates/commands/constitution.md b/templates/commands/constitution.md index 29ae9a09e2..a9fb734594 100644 --- a/templates/commands/constitution.md +++ b/templates/commands/constitution.md @@ -24,24 +24,25 @@ You **MUST** consider the user input before proceeding (if not empty). - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- Set `{command_invocation}` to the user-facing invocation for the hook command; when the active command syntax requires it, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` becomes `__SPECKIT_COMMAND_GIT_COMMIT__`. - For each executable hook, output the following based on its `optional` flag: - **Optional hook** (`optional: true`): ``` ## Extension Hooks **Optional Pre-Hook**: {extension} - Command: `/{command}` + Command: `{command_invocation}` Description: {description} Prompt: {prompt} - To execute: `/{command}` + To execute: `{command_invocation}` ``` - **Mandatory hook** (`optional: false`): ``` ## Extension Hooks **Automatic Pre-Hook**: {extension} - Executing: `/{command}` + Executing: `{command_invocation}` EXECUTE_COMMAND: {command} Wait for the result of the hook command before proceeding to the Outline. @@ -127,24 +128,25 @@ Check if `.specify/extensions.yml` exists in the project root. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- Set `{command_invocation}` to the user-facing invocation for the hook command; when the active command syntax requires it, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` becomes `__SPECKIT_COMMAND_GIT_COMMIT__`. - For each executable hook, output the following based on its `optional` flag: - **Optional hook** (`optional: true`): ``` ## Extension Hooks **Optional Hook**: {extension} - Command: `/{command}` + Command: `{command_invocation}` Description: {description} Prompt: {prompt} - To execute: `/{command}` + To execute: `{command_invocation}` ``` - **Mandatory hook** (`optional: false`): ``` ## Extension Hooks **Automatic Hook**: {extension} - Executing: `/{command}` + Executing: `{command_invocation}` EXECUTE_COMMAND: {command} ``` - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently diff --git a/templates/commands/implement.md b/templates/commands/implement.md index c416fa7387..392da82fbb 100644 --- a/templates/commands/implement.md +++ b/templates/commands/implement.md @@ -23,24 +23,25 @@ You **MUST** consider the user input before proceeding (if not empty). - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- Set `{command_invocation}` to the user-facing invocation for the hook command; when the active command syntax requires it, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` becomes `__SPECKIT_COMMAND_GIT_COMMIT__`. - For each executable hook, output the following based on its `optional` flag: - **Optional hook** (`optional: true`): ``` ## Extension Hooks **Optional Pre-Hook**: {extension} - Command: `/{command}` + Command: `{command_invocation}` Description: {description} Prompt: {prompt} - To execute: `/{command}` + To execute: `{command_invocation}` ``` - **Mandatory hook** (`optional: false`): ``` ## Extension Hooks **Automatic Pre-Hook**: {extension} - Executing: `/{command}` + Executing: `{command_invocation}` EXECUTE_COMMAND: {command} Wait for the result of the hook command before proceeding to the Outline. @@ -183,13 +184,14 @@ Check if `.specify/extensions.yml` exists in the project root. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- Set `{command_invocation}` to the user-facing invocation for the hook command; when the active command syntax requires it, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` becomes `__SPECKIT_COMMAND_GIT_COMMIT__`. - For each executable hook, output the following based on its `optional` flag: - **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**: ``` ## Extension Hooks **Automatic Hook**: {extension} - Executing: `/{command}` + Executing: `{command_invocation}` EXECUTE_COMMAND: {command} ``` - **Optional hook** (`optional: true`): @@ -197,11 +199,11 @@ Check if `.specify/extensions.yml` exists in the project root. ## Extension Hooks **Optional Hook**: {extension} - Command: `/{command}` + Command: `{command_invocation}` Description: {description} Prompt: {prompt} - To execute: `/{command}` + To execute: `{command_invocation}` ``` ## Completion Report diff --git a/templates/commands/plan.md b/templates/commands/plan.md index b44854f452..914b9700e6 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -31,24 +31,25 @@ You **MUST** consider the user input before proceeding (if not empty). - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- Set `{command_invocation}` to the user-facing invocation for the hook command; when the active command syntax requires it, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` becomes `__SPECKIT_COMMAND_GIT_COMMIT__`. - For each executable hook, output the following based on its `optional` flag: - **Optional hook** (`optional: true`): ``` ## Extension Hooks **Optional Pre-Hook**: {extension} - Command: `/{command}` + Command: `{command_invocation}` Description: {description} Prompt: {prompt} - To execute: `/{command}` + To execute: `{command_invocation}` ``` - **Mandatory hook** (`optional: false`): ``` ## Extension Hooks **Automatic Pre-Hook**: {extension} - Executing: `/{command}` + Executing: `{command_invocation}` EXECUTE_COMMAND: {command} Wait for the result of the hook command before proceeding to the Outline. @@ -82,13 +83,14 @@ Check if `.specify/extensions.yml` exists in the project root. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- Set `{command_invocation}` to the user-facing invocation for the hook command; when the active command syntax requires it, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` becomes `__SPECKIT_COMMAND_GIT_COMMIT__`. - For each executable hook, output the following based on its `optional` flag: - **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**: ``` ## Extension Hooks **Automatic Hook**: {extension} - Executing: `/{command}` + Executing: `{command_invocation}` EXECUTE_COMMAND: {command} ``` - **Optional hook** (`optional: true`): @@ -96,11 +98,11 @@ Check if `.specify/extensions.yml` exists in the project root. ## Extension Hooks **Optional Hook**: {extension} - Command: `/{command}` + Command: `{command_invocation}` Description: {description} Prompt: {prompt} - To execute: `/{command}` + To execute: `{command_invocation}` ``` ## Completion Report diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 2fa192fea1..29a45591cb 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -28,24 +28,25 @@ You **MUST** consider the user input before proceeding (if not empty). - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- Set `{command_invocation}` to the user-facing invocation for the hook command; when the active command syntax requires it, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` becomes `__SPECKIT_COMMAND_GIT_COMMIT__`. - For each executable hook, output the following based on its `optional` flag: - **Optional hook** (`optional: true`): ``` ## Extension Hooks **Optional Pre-Hook**: {extension} - Command: `/{command}` + Command: `{command_invocation}` Description: {description} Prompt: {prompt} - To execute: `/{command}` + To execute: `{command_invocation}` ``` - **Mandatory hook** (`optional: false`): ``` ## Extension Hooks **Automatic Pre-Hook**: {extension} - Executing: `/{command}` + Executing: `{command_invocation}` EXECUTE_COMMAND: {command} Wait for the result of the hook command before proceeding to the Outline. @@ -242,13 +243,14 @@ Check if `.specify/extensions.yml` exists in the project root. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- Set `{command_invocation}` to the user-facing invocation for the hook command; when the active command syntax requires it, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` becomes `__SPECKIT_COMMAND_GIT_COMMIT__`. - For each executable hook, output the following based on its `optional` flag: - **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**: ``` ## Extension Hooks **Automatic Hook**: {extension} - Executing: `/{command}` + Executing: `{command_invocation}` EXECUTE_COMMAND: {command} ``` - **Optional hook** (`optional: true`): @@ -256,11 +258,11 @@ Check if `.specify/extensions.yml` exists in the project root. ## Extension Hooks **Optional Hook**: {extension} - Command: `/{command}` + Command: `{command_invocation}` Description: {description} Prompt: {prompt} - To execute: `/{command}` + To execute: `{command_invocation}` ``` ## Completion Report diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md index f863e7787f..ca9d5e3e23 100644 --- a/templates/commands/tasks.md +++ b/templates/commands/tasks.md @@ -32,24 +32,25 @@ You **MUST** consider the user input before proceeding (if not empty). - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- Set `{command_invocation}` to the user-facing invocation for the hook command; when the active command syntax requires it, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` becomes `__SPECKIT_COMMAND_GIT_COMMIT__`. - For each executable hook, output the following based on its `optional` flag: - **Optional hook** (`optional: true`): ``` ## Extension Hooks **Optional Pre-Hook**: {extension} - Command: `/{command}` + Command: `{command_invocation}` Description: {description} Prompt: {prompt} - To execute: `/{command}` + To execute: `{command_invocation}` ``` - **Mandatory hook** (`optional: false`): ``` ## Extension Hooks **Automatic Pre-Hook**: {extension} - Executing: `/{command}` + Executing: `{command_invocation}` EXECUTE_COMMAND: {command} Wait for the result of the hook command before proceeding to the Outline. @@ -102,13 +103,14 @@ Check if `.specify/extensions.yml` exists in the project root. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- Set `{command_invocation}` to the user-facing invocation for the hook command; when the active command syntax requires it, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` becomes `__SPECKIT_COMMAND_GIT_COMMIT__`. - For each executable hook, output the following based on its `optional` flag: - **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**: ``` ## Extension Hooks **Automatic Hook**: {extension} - Executing: `/{command}` + Executing: `{command_invocation}` EXECUTE_COMMAND: {command} ``` - **Optional hook** (`optional: true`): @@ -116,11 +118,11 @@ Check if `.specify/extensions.yml` exists in the project root. ## Extension Hooks **Optional Hook**: {extension} - Command: `/{command}` + Command: `{command_invocation}` Description: {description} Prompt: {prompt} - To execute: `/{command}` + To execute: `{command_invocation}` ``` ## Completion Report diff --git a/templates/commands/taskstoissues.md b/templates/commands/taskstoissues.md index b24e84ee14..9fc24d1c37 100644 --- a/templates/commands/taskstoissues.md +++ b/templates/commands/taskstoissues.md @@ -24,24 +24,25 @@ You **MUST** consider the user input before proceeding (if not empty). - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- Set `{command_invocation}` to the user-facing invocation for the hook command; when the active command syntax requires it, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` becomes `__SPECKIT_COMMAND_GIT_COMMIT__`. - For each executable hook, output the following based on its `optional` flag: - **Optional hook** (`optional: true`): ``` ## Extension Hooks **Optional Pre-Hook**: {extension} - Command: `/{command}` + Command: `{command_invocation}` Description: {description} Prompt: {prompt} - To execute: `/{command}` + To execute: `{command_invocation}` ``` - **Mandatory hook** (`optional: false`): ``` ## Extension Hooks **Automatic Pre-Hook**: {extension} - Executing: `/{command}` + Executing: `{command_invocation}` EXECUTE_COMMAND: {command} Wait for the result of the hook command before proceeding to the Outline. @@ -77,24 +78,25 @@ Check if `.specify/extensions.yml` exists in the project root. - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - If the hook has no `condition` field, or it is null/empty, treat the hook as executable - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation +- Set `{command_invocation}` to the user-facing invocation for the hook command; when the active command syntax requires it, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` becomes `__SPECKIT_COMMAND_GIT_COMMIT__`. - For each executable hook, output the following based on its `optional` flag: - **Optional hook** (`optional: true`): ``` ## Extension Hooks **Optional Hook**: {extension} - Command: `/{command}` + Command: `{command_invocation}` Description: {description} Prompt: {prompt} - To execute: `/{command}` + To execute: `{command_invocation}` ``` - **Mandatory hook** (`optional: false`): ``` ## Extension Hooks **Automatic Hook**: {extension} - Executing: `/{command}` + Executing: `{command_invocation}` EXECUTE_COMMAND: {command} ``` - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index 3b244943b4..2f7b11f016 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -211,6 +211,34 @@ def test_skills_extension_command_with_args(self): assert i.build_command_invocation("speckit.git.commit", "fix typo") == "/speckit-git-commit fix typo" +class TestBuildUserCommandInvocation: + """Tests for user-facing command hints across integration types.""" + + def test_base_defaults_to_slash_dot(self): + i = StubIntegration() + assert i.build_user_command_invocation("speckit.plan") == "/speckit.plan" + assert i.build_user_command_invocation("plan", "my feature") == "/speckit.plan my feature" + + def test_base_extension_command(self): + i = StubIntegration() + assert i.build_user_command_invocation("speckit.git.commit") == "/speckit.git.commit" + + def test_skills_default_to_slash_hyphen(self): + from specify_cli.integrations import get_integration + i = get_integration("claude") + assert i.build_user_command_invocation("speckit.plan") == "/speckit-plan" + assert i.build_user_command_invocation("speckit.git.commit") == "/speckit-git-commit" + + def test_custom_prefix_and_separator(self): + class DollarSkills(SkillsIntegration): + key = "dollar" + user_command_prefix = "$" + + i = DollarSkills() + assert i.build_user_command_invocation("speckit.plan") == "$speckit-plan" + assert i.build_user_command_invocation("git.commit", "now") == "$speckit-git-commit now" + + class TestResolveCommandRefs: """Tests for __SPECKIT_COMMAND___ placeholder resolution.""" @@ -239,6 +267,19 @@ def test_extension_command_hyphen(self): result = IntegrationBase.resolve_command_refs(text, "-") assert result == "Run /speckit-git-commit to commit." + def test_custom_prefix_hyphen(self): + text = "Run __SPECKIT_COMMAND_GIT_COMMIT__ to commit." + result = IntegrationBase.resolve_command_refs(text, "-", prefix="$") + assert result == "Run $speckit-git-commit to commit." + + def test_custom_command_builder(self): + text = "Run __SPECKIT_COMMAND_PLAN__ then __SPECKIT_COMMAND_GIT_COMMIT__." + result = IntegrationBase.resolve_command_refs( + text, + command_builder=lambda command: f"custom:{command}", + ) + assert result == "Run custom:speckit.plan then custom:speckit.git.commit." + def test_no_placeholders_unchanged(self): text = "No placeholders here." assert IntegrationBase.resolve_command_refs(text, ".") == text @@ -274,6 +315,15 @@ def test_process_template_skills_separator(self): assert "/speckit-plan" in result assert "__SPECKIT_COMMAND_" not in result + def test_process_template_custom_prefix(self): + content = "---\ndescription: test\n---\nRun __SPECKIT_COMMAND_PLAN__ now." + result = IntegrationBase.process_template( + content, "test-agent", "sh", invoke_separator="-", command_prefix="$" + ) + assert "$speckit-plan" in result + assert "/speckit-plan" not 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 diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 0c793cd7fa..0d5341c043 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -991,6 +991,24 @@ def test_hyphen_separator_in_page_templates(self, tmp_path): assert "__SPECKIT_COMMAND_" not in content assert "/speckit-tasks" in content + def test_custom_prefix_in_page_templates(self, tmp_path): + """Codex-style skills get $speckit- in page templates.""" + from specify_cli import _install_shared_infra + + project = tmp_path / "prefix-test" + project.mkdir() + (project / ".specify").mkdir() + + _install_shared_infra(project, "sh", invoke_separator="-", command_prefix="$") + + plan = project / ".specify" / "templates" / "plan-template.md" + assert plan.exists() + content = plan.read_text(encoding="utf-8") + assert "__SPECKIT_COMMAND_" not in content + assert "$speckit-plan" in content + assert "/speckit-plan" not in content + assert "/speckit.plan" not in content + @pytest.mark.parametrize("script_type", ["sh", "ps"]) def test_dot_separator_in_shared_scripts(self, tmp_path, script_type): """Markdown agents get /speckit. in shared script hints.""" @@ -1031,6 +1049,31 @@ def test_hyphen_separator_in_shared_scripts(self, tmp_path, script_type): assert "/speckit.plan" not in content assert "/speckit.tasks" not in content + @pytest.mark.parametrize("script_type", ["sh", "ps"]) + def test_custom_prefix_in_shared_scripts(self, tmp_path, script_type): + """Codex-style skills get $speckit- in shared script hints.""" + from specify_cli import _install_shared_infra + + project = tmp_path / f"prefix-script-{script_type}" + project.mkdir() + (project / ".specify").mkdir() + + _install_shared_infra( + project, script_type, invoke_separator="-", command_prefix="$" + ) + + content = self._combined_script_content(project, script_type) + assert "__SPECKIT_COMMAND_" not in content + assert "$speckit-specify" in content + assert "$speckit-plan" in content + assert "$speckit-tasks" in content + assert "/speckit-specify" not in content + assert "/speckit.plan" not in content + if script_type == "sh": + assert r"Run \$speckit-plan first" in content + else: + assert "Run `$speckit-plan first" 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 @@ -1126,6 +1169,39 @@ def test_full_init_copilot_skills_resolves_page_templates(self, tmp_path): assert "/speckit-specify" in script_content assert "/speckit.specify" not in script_content + def test_full_init_codex_resolves_page_templates(self, tmp_path): + """Full CLI init with Codex produces dollar refs in page templates.""" + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + project = tmp_path / "init-codex" + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, [ + "init", str(project), + "--integration", "codex", + "--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, "Codex should use $speckit-plan" + assert "/speckit-plan" not in content + assert "/speckit.plan" not in content + assert "__SPECKIT_COMMAND_" not in content + + script_content = self._combined_script_content(project, "sh") + assert "$speckit-specify" in script_content + assert "/speckit-specify" not in script_content + class TestIntegrationCatalogDiscoveryCLI: """End-to-end CLI tests for `integration search`, `info`, and `catalog …`. diff --git a/tests/integrations/test_integration_base_markdown.py b/tests/integrations/test_integration_base_markdown.py index 48af5bd33b..77959f957e 100644 --- a/tests/integrations/test_integration_base_markdown.py +++ b/tests/integrations/test_integration_base_markdown.py @@ -101,6 +101,12 @@ def test_templates_are_processed(self, tmp_path): 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" + def test_display_invocation_defaults_to_slash_dot(self): + """Markdown integrations keep slash-dot visible command guidance.""" + i = get_integration(self.KEY) + assert i.build_user_command_invocation("speckit.plan") == "/speckit.plan" + assert i.build_user_command_invocation("speckit.git.commit") == "/speckit.git.commit" + def test_plan_references_correct_context_file(self, tmp_path): """The generated plan command must reference this integration's context file.""" i = get_integration(self.KEY) diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index d6c913b70c..b1bb492b78 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -176,6 +176,14 @@ def test_command_refs_use_hyphen_separator(self, tmp_path): f"skills agents must use /speckit-" ) + def test_display_invocation_defaults_to_slash_hyphen(self): + """Non-Codex skills keep slash-hyphen visible command guidance.""" + i = get_integration(self.KEY) + if i.user_command_prefix != "/": + return + assert i.build_user_command_invocation("speckit.plan") == "/speckit-plan" + assert i.build_user_command_invocation("speckit.git.commit") == "/speckit-git-commit" + def test_hook_sections_explain_dotted_command_conversion(self, tmp_path): """Generated skills with hook sections must explain dotted command conversion.""" i = get_integration(self.KEY) @@ -184,22 +192,23 @@ def test_hook_sections_explain_dotted_command_conversion(self, tmp_path): specify_skill = i.skills_dest(tmp_path) / "speckit-specify" / "SKILL.md" assert specify_skill.exists() content = specify_skill.read_text(encoding="utf-8") - assert "replace dots" in content, ( + expected_example = i.build_user_command_invocation("speckit.git.commit") + assert expected_example in content, ( "speckit-specify should explain dotted hook command conversion" ) - assert content.count("replace dots") == content.count( + assert content.count(expected_example) == content.count( "- For each executable hook, output the following" ) def test_hook_note_injected_for_each_instruction_independently(self): """Existing hook notes should not suppress later missing notes.""" + from specify_cli.integrations.base import _HOOK_COMMAND_NOTE + content = ( "---\n" "name: test\n" "---\n\n" - "- When constructing slash commands from hook command names, " - "replace dots (`.`) with hyphens (`-`). " - "For example, `speckit.git.commit` → `/speckit-git-commit`.\n" + f"{_HOOK_COMMAND_NOTE}" "- For each executable hook, output the following first block:\n" "\n" "- For each executable hook, output the following second block:\n" @@ -207,7 +216,7 @@ def test_hook_note_injected_for_each_instruction_independently(self): result = SkillsIntegration._inject_hook_command_note(content) - assert result.count("replace dots (`.`) with hyphens") == 2 + assert result.count("/speckit-git-commit") == 2 def test_skill_body_has_content(self, tmp_path): """Each SKILL.md body should contain template content after the frontmatter.""" diff --git a/tests/integrations/test_integration_codex.py b/tests/integrations/test_integration_codex.py index 52aa84344e..cb06bcea48 100644 --- a/tests/integrations/test_integration_codex.py +++ b/tests/integrations/test_integration_codex.py @@ -14,6 +14,39 @@ class TestCodexIntegration(SkillsIntegrationTests): CONTEXT_FILE = "AGENTS.md" +class TestCodexDisplayInvocation: + """Codex uses dollar-prefixed skills in user-facing guidance.""" + + def test_codex_builds_dollar_prefixed_display_invocations(self): + i = get_integration("codex") + + assert i.build_user_command_invocation("speckit.plan") == "$speckit-plan" + assert i.build_user_command_invocation("plan") == "$speckit-plan" + assert ( + i.build_user_command_invocation("speckit.git.commit", "fix typo") + == "$speckit-git-commit fix typo" + ) + + def test_codex_generated_skills_use_dollar_command_refs(self, tmp_path): + i = get_integration("codex") + m = IntegrationManifest("codex", tmp_path) + i.setup(tmp_path, m, script_type="sh") + + specify_skill = tmp_path / ".agents/skills/speckit-specify/SKILL.md" + tasks_skill = tmp_path / ".agents/skills/speckit-tasks/SKILL.md" + + specify_content = specify_skill.read_text(encoding="utf-8") + tasks_content = tasks_skill.read_text(encoding="utf-8") + combined = "\n".join([specify_content, tasks_content]) + + assert "$speckit-plan" in specify_content + assert "$speckit-git-commit" in combined + assert "/speckit-plan" not in combined + assert "/speckit.plan" not in combined + assert "/speckit-git-commit" not in combined + assert "/speckit.git.commit" not in combined + + class TestCodexAutoPromote: """--ai codex auto-promotes to integration path.""" @@ -47,8 +80,8 @@ def test_hook_note_injected_in_skills_with_hooks(self, tmp_path): specify_skill = tmp_path / ".agents/skills/speckit-specify/SKILL.md" assert specify_skill.exists() content = specify_skill.read_text(encoding="utf-8") - assert "replace dots" in content, ( - "speckit-specify should have dot-to-hyphen hook note" + assert "$speckit-git-commit" in content, ( + "speckit-specify should have Codex hook command guidance" ) def test_hook_note_not_in_skills_without_hooks(self): @@ -57,7 +90,7 @@ def test_hook_note_not_in_skills_without_hooks(self): content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n" result = CodexIntegration._inject_hook_command_note(content) - assert "replace dots" not in result + assert "speckit-git-commit" not in result def test_hook_note_idempotent(self): """Injecting the note twice should not duplicate it.""" @@ -84,7 +117,7 @@ def test_hook_note_fills_missing_repeated_instructions(self): " - For each executable hook, output the following based on its flag:\n" ) result = CodexIntegration._inject_hook_command_note(content) - assert result.count("replace dots (`.`) with hyphens") == 2 + assert result.count("$speckit-git-commit") == 2 def test_hook_note_not_suppressed_by_unrelated_phrase(self): """Unrelated text should not trip the hook-note idempotence guard.""" @@ -97,7 +130,7 @@ def test_hook_note_not_suppressed_by_unrelated_phrase(self): ) result = CodexIntegration._inject_hook_command_note(content) assert "This paragraph says replace dots in a different context." in result - assert result.count("replace dots (`.`) with hyphens") == 1 + assert result.count("$speckit-git-commit") == 1 def test_hook_note_preserves_indentation(self): """The injected note should match the indentation of the target line.""" @@ -109,7 +142,7 @@ def test_hook_note_preserves_indentation(self): ) result = CodexIntegration._inject_hook_command_note(content) lines = result.splitlines() - note_line = [line for line in lines if "replace dots" in line][0] + note_line = [line for line in lines if "$speckit-git-commit" in line][0] assert note_line.startswith(" "), "Note should preserve indentation" def test_hook_note_when_instruction_is_final_line_without_newline(self): @@ -130,7 +163,7 @@ def test_hook_note_when_instruction_is_final_line_without_newline(self): result = CodexIntegration._inject_hook_command_note(content) lines = result.splitlines() note_line_idx = next( - i for i, line in enumerate(lines) if "replace dots" in line + i for i, line in enumerate(lines) if "$speckit-git-commit" in line ) instruction_line_idx = next( i for i, line in enumerate(lines) diff --git a/tests/integrations/test_integration_state.py b/tests/integrations/test_integration_state.py index 1d6bdb0268..ecb8890b45 100644 --- a/tests/integrations/test_integration_state.py +++ b/tests/integrations/test_integration_state.py @@ -9,6 +9,10 @@ normalize_integration_state, write_integration_json, ) +from specify_cli.integration_runtime import ( + command_prefix_for_integration, + user_command_invocation_for_integration, +) def test_normalize_integration_state_strips_default_key_without_duplicates(): @@ -63,6 +67,7 @@ def test_integration_settings_strip_invoke_separator(): "integration_settings": { "claude": { "invoke_separator": " - ", + "command_prefix": " $ ", } } }, @@ -70,6 +75,7 @@ def test_integration_settings_strip_invoke_separator(): ) assert setting["invoke_separator"] == "-" + assert setting["command_prefix"] == "$" def test_write_integration_json_strips_integration_key(tmp_path): @@ -84,3 +90,29 @@ def test_write_integration_json_strips_integration_key(tmp_path): assert state["integration"] == "claude" assert state["default_integration"] == "claude" assert state["installed_integrations"] == ["claude"] + + +def test_command_prefix_for_integration_uses_integration_default(): + from specify_cli.integrations import get_integration + + codex = get_integration("codex") + assert command_prefix_for_integration(codex, {}, "codex") == "$" + + +def test_user_command_invocation_for_integration_uses_stored_options(): + from specify_cli.integrations import get_integration + + copilot = get_integration("copilot") + state = { + "integration_settings": { + "copilot": { + "invoke_separator": "-", + "parsed_options": {"skills": True}, + } + } + } + + assert ( + user_command_invocation_for_integration(copilot, state, "copilot", "speckit.plan") + == "/speckit-plan" + ) diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index f40adb7ae9..6bbb12b862 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -1184,7 +1184,9 @@ def test_failed_switch_keeps_fallback_metadata_consistent(self, tmp_path): assert opts["ai"] == "codex" template = project / ".specify" / "templates" / "plan-template.md" - assert "/speckit-plan" in template.read_text(encoding="utf-8") + content = template.read_text(encoding="utf-8") + assert "$speckit-plan" in content + assert "/speckit-plan" not in content class TestIntegrationUpgrade: diff --git a/tests/test_init_command_invocations.py b/tests/test_init_command_invocations.py new file mode 100644 index 0000000000..628fcae7c7 --- /dev/null +++ b/tests/test_init_command_invocations.py @@ -0,0 +1,91 @@ +"""Regression tests for integration-aware init command hints.""" + +from typer.testing import CliRunner + +from tests.conftest import strip_ansi + + +def _clean_output(output: str) -> str: + return strip_ansi(output) + + +def test_init_codex_next_steps_use_dollar_skills(tmp_path): + from specify_cli import app + + project = tmp_path / "codex-init" + result = CliRunner().invoke( + app, + [ + "init", + str(project), + "--integration", + "codex", + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + + output = _clean_output(result.output) + assert result.exit_code == 0, result.output + assert "$speckit-plan" in output + assert "$speckit-analyze" in output + assert "/speckit-plan" not in output + assert "/speckit.plan" not in output + + +def test_init_copilot_next_steps_keep_slash_dot(tmp_path): + from specify_cli import app + + project = tmp_path / "copilot-init" + result = CliRunner().invoke( + app, + [ + "init", + str(project), + "--integration", + "copilot", + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + + output = _clean_output(result.output) + assert result.exit_code == 0, result.output + assert "/speckit.plan" in output + assert "/speckit.tasks" in output + assert "$speckit-plan" not in output + + +def test_init_copilot_skills_next_steps_keep_slash_hyphen(tmp_path): + from specify_cli import app + + project = tmp_path / "copilot-skills-init" + result = CliRunner().invoke( + app, + [ + "init", + str(project), + "--integration", + "copilot", + "--integration-options", + "--skills", + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + + output = _clean_output(result.output) + assert result.exit_code == 0, result.output + assert "/speckit-plan" in output + assert "/speckit-tasks" in output + assert "/speckit.plan" not in output + assert "$speckit-plan" not in output