From 655a3cb8ca337a9f52e2c60b2189904567c0ccbd Mon Sep 17 00:00:00 2001 From: Ben Buttigieg <70525+BenBtg@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:48:40 +0100 Subject: [PATCH] fix(integrations): preserve native skill invocation prefixes (#3663) * fix(integrations): use native dollar skill invocations Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * fix(integrations): preserve skill post-process idempotence Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * fix(integrations): preserve literal skill invocations Resolve generated command references with the active agent prefix instead of rewriting all slash-form text during post-processing. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * fix(integrations): preserve shared invocation prefix Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * fix(integrations): preserve install invocation prefix Pass dollar-style skill prefixes through bare-project integration installation and cover the shared template output. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * fix(integrations): preserve dollar refs everywhere Use agent-native invocation prefixes in extension command registration and dynamic shared-script command hints. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * fix(shared-infra): preserve dollar command hints Escape dollar-prefixed commands embedded in Bash strings and propagate the native prefix into installed Python command helpers. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * fix(shared-infra): render native helper prefixes Rewrite installed Bash and PowerShell formatter return expressions so direct callers receive the selected integration's native prefix. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * fix(skills): use invocation-neutral hook guidance Describe hook-derived references as command invocations so dollar-prefixed skills do not receive contradictory slash-command terminology. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * test(integrations): expect native fallback invocation Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * refactor(integrations): centralize invocation prefix selection Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 * fix(integrations): add Kimi /skill: prefix and fix docstrings - Add SKILL_COLON_AGENTS frozenset and get_invocation_prefix() to _invocation_style.py so Kimi resolves to '/skill:' in skills mode - Switch invoke_prefix_for_integration() to use get_invocation_prefix() instead of the binary dollar/slash check - Update post_process_skill_content docstring (base.py) to cover both slash and dollar native invocation forms - Update _resolve_command_refs_in_skill docstring (presets/__init__.py) to document the dollar-prefixed result alongside slash forms Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) * fix(agents): use get_invocation_prefix for Kimi in register_commands Replace the binary is_dollar_skills_agent ternary with get_invocation_prefix so that __SPECKIT_COMMAND_*__ tokens in Kimi skill files resolve to /skill:speckit- rather than /speckit-. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) * fix(agents): remove unused is_dollar_skills_agent import Leftover from replacing the inline ternary with get_invocation_prefix. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) * fix(integrations): use get_invocation_prefix in post_process_skill_content Replaces the binary is_dollar_skills_agent ternary with get_invocation_prefix so that Kimi's hook-command note is injected as /skill:speckit-git-commit from the start. This keeps _inject_hook_command_note idempotent for Kimi: the previous note with its native prefix now matches on repeated passes, preventing duplicate note injection. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) * fix(presets): use get_invocation_prefix in _resolve_skill_command_refs Replace the binary is_dollar_skills_agent ternary with get_invocation_prefix so Kimi tokens resolve to /skill:speckit-* directly rather than /speckit-* (which previously relied on the broad post-process body replacement). Also fix test_restore_skill_preserves_dollar_command_refs to write raw_core with the unresolved __SPECKIT_COMMAND_PLAN__ token, exercising the resolver rather than bypassing it with a pre-resolved string. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) * docs(presets): document /skill: form in _resolve_skill_command_refs Add /skill:speckit- to the docstring so the contract covers all three native prefix forms returned by get_invocation_prefix. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) * test(integrations): add Kimi /skill: prefix coverage - test_skill_colon_prefix_core_command: resolve_command_refs with /skill: prefix - test_get_invocation_prefix_skill_colon: get_invocation_prefix returns /skill: for kimi (skills), / for kimi (non-skills), $ for codex, / for claude - test_kimi_skill_post_processing_is_idempotent: verifies Kimi's hook-command note is injected with /skill: prefix and does not duplicate on re-runs - test_installed_bash_formatter_uses_skill_colon_prefix: shared-infra bash formatter outputs /skill:speckit-plan when installed with /skill: prefix Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) * fix(kimi): use get_invocation_prefix in process_template, remove broad replacement process_template() was still using a binary is_dollar_skills_agent ternary to select between dollar and slash prefix, so Kimi tokens were emitted as /speckit-* and then corrected by a broad .replace('/speckit-', '/skill:speckit-') in KimiIntegration.post_process_skill_content(). That broad replacement would also rewrite any literal /speckit-* text in generated skill content, contrary to the PR's token-only behavior. - Use get_invocation_prefix(agent_name, invoke_separator == '-') in process_template() so Kimi tokens are emitted as /skill:speckit-* directly. - Remove the broad .replace() from KimiIntegration.post_process_skill_content(); it is now a no-op (tokens are already correctly prefixed at source). - Add test_process_template_kimi_uses_skill_colon_prefix to guard the fix. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02f9e138-da58-4a60-93b9-eae659d2aa19 Copilot-Session: 65ef91d9-4c31-4f31-a009-ed2093fe7f28 --- src/specify_cli/__init__.py | 6 ++ src/specify_cli/_invocation_style.py | 18 +++++ src/specify_cli/agents.py | 8 +- src/specify_cli/commands/init.py | 7 ++ src/specify_cli/integration_runtime.py | 12 +++ src/specify_cli/integrations/_helpers.py | 4 + .../integrations/_install_commands.py | 4 + .../integrations/_migrate_commands.py | 10 +++ src/specify_cli/integrations/base.py | 45 ++++++++--- src/specify_cli/integrations/kimi/__init__.py | 3 +- src/specify_cli/presets/__init__.py | 16 ++-- src/specify_cli/shared_infra.py | 59 +++++++++++--- tests/integrations/test_base.py | 80 +++++++++++++++++-- tests/integrations/test_cli.py | 60 ++++++++++++++ .../test_integration_base_skills.py | 2 +- tests/integrations/test_integration_codex.py | 3 +- .../test_integration_subcommand.py | 20 ++++- tests/integrations/test_integration_zcode.py | 1 - tests/test_extensions.py | 23 ++++++ tests/test_presets.py | 29 +++++++ tests/test_setup_tasks.py | 54 +++++++++++++ 21 files changed, 423 insertions(+), 41 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 1d5a34073..0454b6923 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -114,6 +114,7 @@ def _refresh_shared_templates( project_path: Path, *, invoke_separator: str, + invoke_prefix: str = "/", force: bool = False, ) -> None: """Refresh default-sensitive shared templates without touching scripts.""" @@ -124,6 +125,7 @@ def _refresh_shared_templates( repo_root=_repo_root(), console=console, invoke_separator=invoke_separator, + invoke_prefix=invoke_prefix, force=force, ) @@ -134,6 +136,7 @@ def _install_shared_infra( tracker: StepTracker | None = None, force: bool = False, invoke_separator: str = ".", + invoke_prefix: str = "/", refresh_managed: bool = False, refresh_hint: str | None = None, ) -> bool: @@ -177,6 +180,7 @@ def _install_shared_infra( console=console, force=force, invoke_separator=invoke_separator, + invoke_prefix=invoke_prefix, refresh_managed=refresh_managed, refresh_hint=refresh_hint, ) @@ -188,6 +192,7 @@ def _install_shared_infra_or_exit( tracker: StepTracker | None = None, force: bool = False, invoke_separator: str = ".", + invoke_prefix: str = "/", refresh_managed: bool = False, refresh_hint: str | None = None, ) -> bool: @@ -198,6 +203,7 @@ def _install_shared_infra_or_exit( tracker=tracker, force=force, invoke_separator=invoke_separator, + invoke_prefix=invoke_prefix, refresh_managed=refresh_managed, refresh_hint=refresh_hint, ) diff --git a/src/specify_cli/_invocation_style.py b/src/specify_cli/_invocation_style.py index 2b5f18948..30b42eb08 100644 --- a/src/specify_cli/_invocation_style.py +++ b/src/specify_cli/_invocation_style.py @@ -29,6 +29,9 @@ CONDITIONAL_SLASH_AGENTS: frozenset[str] = frozenset( } ) +# Agents that render /skill: (skill-colon invocation) when in skills mode. +SKILL_COLON_AGENTS: frozenset[str] = frozenset({"kimi"}) + def is_dollar_skills_agent(selected_ai: str | None, ai_skills_enabled: bool) -> bool: """Return ``True`` if *selected_ai* uses ``$speckit-`` invocations. @@ -41,6 +44,21 @@ def is_dollar_skills_agent(selected_ai: str | None, ai_skills_enabled: bool) -> return selected_ai in DOLLAR_SKILLS_AGENTS and ai_skills_enabled +def get_invocation_prefix(selected_ai: str | None, ai_skills_enabled: bool) -> str: + """Return the native invocation prefix for *selected_ai* in skills mode. + + Returns ``"$"`` for dollar-skills agents (Codex, ZCode), + ``"/skill:"`` for skill-colon agents (Kimi), and ``"/"`` for all others. + """ + if not isinstance(selected_ai, str): + return "/" + if selected_ai in DOLLAR_SKILLS_AGENTS and ai_skills_enabled: + return "$" + if selected_ai in SKILL_COLON_AGENTS and ai_skills_enabled: + return "/skill:" + return "/" + + def is_slash_skills_agent(selected_ai: str | None, ai_skills_enabled: bool) -> bool: """Return ``True`` if *selected_ai* uses ``/speckit-`` invocations. diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 41a97cca7..a998f3992 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -15,6 +15,7 @@ from typing import Any, Dict, Iterable, List, Optional import yaml from ._init_options import is_ai_skills_enabled, load_init_options +from ._invocation_style import get_invocation_prefix from ._toml_string import escape_toml_basic as _escape_toml_basic from ._toml_string import has_illegal_toml_control as _has_illegal_toml_control from ._utils import relative_extension_path_violation @@ -659,17 +660,16 @@ class CommandRegistrar: # correct when a stale ``.bob/skills`` directory coexists with # ``.bob/commands``. _sep = agent_config.get("invoke_separator", ".") + registrar_writes_skills = agent_config.get("extension") == "/SKILL.md" try: from specify_cli.integrations import get_integration # noqa: PLC0415 _integ = get_integration(agent_name) if _integ is not None: - registrar_writes_skills = ( - agent_config.get("extension") == "/SKILL.md" - ) _sep = _integ.invoke_separator_for_mode(registrar_writes_skills) except Exception: pass + _prefix = get_invocation_prefix(agent_name, registrar_writes_skills) for cmd_info in commands: cmd_name = cmd_info["name"] @@ -772,7 +772,7 @@ class CommandRegistrar: # (base.py itself imports CommandRegistrar lazily). from specify_cli.integrations.base import IntegrationBase # noqa: PLC0415 - body = IntegrationBase.resolve_command_refs(body, _sep) + body = IntegrationBase.resolve_command_refs(body, _sep, _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 ccdd7b0a1..0d96e2b6c 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -183,6 +183,7 @@ def register(app: typer.Typer) -> None: save_init_options, ) from ..integration_runtime import ( + invoke_prefix_for_integration as _invoke_prefix_for_integration, with_integration_setting as _with_integration_setting, ) from ..integrations._commands import ( @@ -481,6 +482,12 @@ def register(app: typer.Typer) -> None: invoke_separator=resolved_integration.effective_invoke_separator( integration_parsed_options, project_root=project_path ), + invoke_prefix=_invoke_prefix_for_integration( + resolved_integration, + resolved_integration.key, + integration_parsed_options, + project_path, + ), ) tracker.complete( "shared-infra", f"scripts ({selected_script}) + templates" diff --git a/src/specify_cli/integration_runtime.py b/src/specify_cli/integration_runtime.py index 98bc6a05b..eef44574c 100644 --- a/src/specify_cli/integration_runtime.py +++ b/src/specify_cli/integration_runtime.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections.abc import Callable from typing import Any +from ._invocation_style import get_invocation_prefix from .integration_state import integration_setting, integration_settings @@ -99,3 +100,14 @@ def invoke_separator_for_integration( return integration.effective_invoke_separator(stored_parsed, project_root) return integration.effective_invoke_separator(None, project_root) + + +def invoke_prefix_for_integration( + integration: Any, + key: str, + parsed_options: dict[str, Any] | None = None, + project_root: Any = None, +) -> str: + """Resolve the native invocation prefix for an integration's output mode.""" + skills_mode = integration.is_skills_mode(parsed_options, project_root) + return get_invocation_prefix(key, skills_mode) diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index b95830b07..5c16935f1 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -11,6 +11,7 @@ from rich.markup import escape from .._agent_config import SCRIPT_TYPE_CHOICES from .._console import console from ..integration_runtime import ( + invoke_prefix_for_integration as _invoke_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, @@ -333,6 +334,9 @@ def _set_default_integration( integration, {"integration_settings": settings}, key, parsed_options, project_root=project_root, ), + invoke_prefix=_invoke_prefix_for_integration( + integration, key, parsed_options, project_root + ), force=refresh_templates_force, refresh_managed=True, refresh_hint=refresh_hint, diff --git a/src/specify_cli/integrations/_install_commands.py b/src/specify_cli/integrations/_install_commands.py index 4d7bb44f9..372a335fd 100644 --- a/src/specify_cli/integrations/_install_commands.py +++ b/src/specify_cli/integrations/_install_commands.py @@ -8,6 +8,7 @@ import typer from .._console import console from .._utils import _display_project_path from ..integration_runtime import ( + invoke_prefix_for_integration as _invoke_prefix_for_integration, invoke_separator_for_integration as _invoke_separator_for_integration, with_integration_setting as _with_integration_setting, ) @@ -130,6 +131,9 @@ def integration_install( infra_integration, current, infra_key, infra_parsed, project_root=project_root, ), + invoke_prefix=_invoke_prefix_for_integration( + infra_integration, infra_key, infra_parsed, project_root + ), ) if os.name != "nt": from .. import ensure_executable_scripts diff --git a/src/specify_cli/integrations/_migrate_commands.py b/src/specify_cli/integrations/_migrate_commands.py index f0c622323..0ff617ad2 100644 --- a/src/specify_cli/integrations/_migrate_commands.py +++ b/src/specify_cli/integrations/_migrate_commands.py @@ -9,6 +9,7 @@ import typer from .._console import console from ..integration_runtime import ( + invoke_prefix_for_integration as _invoke_prefix_for_integration, invoke_separator_for_integration as _invoke_separator_for_integration, with_integration_setting as _with_integration_setting, ) @@ -447,6 +448,9 @@ def integration_switch( target_integration, current, target, parsed_options, project_root=project_root, ), + invoke_prefix=_invoke_prefix_for_integration( + target_integration, target, parsed_options, project_root + ), refresh_hint=( "To overwrite customizations, re-run with " "[cyan]specify integration switch ... --refresh-shared-infra[/cyan]." @@ -747,6 +751,9 @@ def integration_upgrade( infra_integration, current, infra_key, infra_parsed, project_root=project_root, ), + invoke_prefix=_invoke_prefix_for_integration( + infra_integration, infra_key, infra_parsed, project_root + ), ) if os.name != "nt": from .. import ensure_executable_scripts @@ -782,6 +789,9 @@ def integration_upgrade( integration, {"integration_settings": settings}, key, parsed_options, project_root=project_root, ), + invoke_prefix=_invoke_prefix_for_integration( + integration, key, parsed_options, project_root + ), force=force, refresh_managed=True, ) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index ccc858770..da255ed86 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -27,6 +27,7 @@ from typing import TYPE_CHECKING, Any import yaml +from .._invocation_style import get_invocation_prefix, is_dollar_skills_agent from .._toml_string import escape_toml_basic as _escape_toml_basic from .._toml_string import has_illegal_toml_control as _has_illegal_toml_control @@ -34,7 +35,7 @@ if TYPE_CHECKING: 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" ) @@ -601,7 +602,9 @@ class IntegrationBase(ABC): return created @staticmethod - def resolve_command_refs(content: str, separator: str = ".") -> str: + def resolve_command_refs( + content: str, separator: str = ".", prefix: str = "/" + ) -> str: """Replace ``__SPECKIT_COMMAND___`` placeholders with invocations. Each placeholder encodes a command name in upper-case with @@ -611,10 +614,16 @@ class IntegrationBase(ABC): * ``separator="."`` → ``/speckit.plan``, ``/speckit.git.commit`` * ``separator="-"`` → ``/speckit-plan``, ``/speckit-git-commit`` + + *prefix* defaults to ``"/"`` but may be ``"$"`` for agents whose + native skills invocation uses dollar-prefixed chat commands. """ return re.sub( r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", - lambda m: "/speckit" + separator + m.group(1).lower().replace("_", separator), + lambda m: prefix + + "speckit" + + separator + + m.group(1).lower().replace("_", separator), content, ) @@ -838,7 +847,12 @@ class IntegrationBase(ABC): content = CommandRegistrar.rewrite_project_relative_paths(content) # 8. Replace __SPECKIT_COMMAND___ with invocation strings - content = IntegrationBase.resolve_command_refs(content, invoke_separator) + invocation_prefix = get_invocation_prefix( + agent_name, invoke_separator == "-" + ) + content = IntegrationBase.resolve_command_refs( + content, invoke_separator, invocation_prefix + ) return content @@ -1520,18 +1534,21 @@ class SkillsIntegration(IntegrationBase): return project_root / folder / subdir def build_command_invocation(self, command_name: str, args: str = "") -> str: - """Skills use ``/speckit-`` (hyphenated directory name).""" + """Build the agent's native invocation for a hyphenated skill name.""" stem = command_name if stem.startswith("speckit."): stem = stem[len("speckit."):] - invocation = "/speckit-" + stem.replace(".", "-") + prefix = "$" if is_dollar_skills_agent(self.key, True) else "/" + invocation = prefix + "speckit-" + stem.replace(".", "-") if args: invocation = f"{invocation} {args}" return invocation @staticmethod - def _inject_hook_command_note(content: str) -> str: + def _inject_hook_command_note( + content: str, invocation_prefix: str = "/" + ) -> str: """Insert a dot-to-hyphen note before each hook output instruction. Targets the line ``- For each executable hook, output the following`` @@ -1540,6 +1557,11 @@ class SkillsIntegration(IntegrationBase): above them. """ note = _HOOK_COMMAND_NOTE.rstrip("\n") + if invocation_prefix != "/": + note = note.replace( + "`/speckit-git-commit`", + f"`{invocation_prefix}speckit-git-commit`", + ) def repl(m: re.Match[str]) -> str: indent = m.group(1) @@ -1573,10 +1595,13 @@ class SkillsIntegration(IntegrationBase): Called by external skill generators (presets, extensions) to let the integration inject agent-specific frontmatter or body transformations. The base implementation injects shared skills - guidance for converting dotted hook command names to hyphenated - slash commands. Subclasses may override — see ``ClaudeIntegration``. + guidance for converting dotted hook command names to the agent-native + hyphenated command invocation (e.g. ``/speckit-git-commit`` or + ``$speckit-git-commit``). Subclasses may override -- see + ``ClaudeIntegration``. """ - return self._inject_hook_command_note(content) + invocation_prefix = get_invocation_prefix(self.key, True) + return self._inject_hook_command_note(content, invocation_prefix) def setup( self, diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py index 3320935a0..2b3d409b6 100644 --- a/src/specify_cli/integrations/kimi/__init__.py +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -59,8 +59,7 @@ class KimiIntegration(SkillsIntegration): def post_process_skill_content(self, content: str) -> str: """Ensure in-skill cross-command references use Kimi's `/skill:` syntax.""" - content = super().post_process_skill_content(content) - return content.replace("/speckit-", "/skill:speckit-") + return super().post_process_skill_content(content) @classmethod def options(cls) -> list[IntegrationOption]: diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index b9ca6e6d6..e16a9d325 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -40,6 +40,7 @@ from .._init_options import ( load_init_options, resolve_active_agent_for_registration, ) +from .._invocation_style import get_invocation_prefix from ..integrations.base import IntegrationBase from .._utils import dump_frontmatter, version_satisfies from ..shared_infra import ( @@ -2424,13 +2425,15 @@ class PresetManager: Looks up the agent's invoke separator and 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()``. + agent-native invocation -- ``/speckit-`` or ``$speckit-`` for + a ``-`` separator, ``/speckit.`` for ``.``, or + ``/skill:speckit-`` for skill-colon agents (e.g. Kimi) -- the + same rendering the command layer applies via + ``CommandRegistrar.register_commands()``. For dual-layout agents (e.g. Bob) the separator depends on the - project's persisted skills state, so — when *project_root* is provided - — the separator is resolved from the integration via + project's persisted skills state, so -- when *project_root* is provided + -- the separator is resolved from the integration via ``invoke_separator_for_mode`` rather than the single static ``AGENT_CONFIGS`` value. """ @@ -2451,7 +2454,8 @@ class PresetManager: separator = registrar.AGENT_CONFIGS.get(selected_ai, {}).get( "invoke_separator", "." ) - return IntegrationBase.resolve_command_refs(body, separator) + prefix = get_invocation_prefix(selected_ai, separator == "-") + return IntegrationBase.resolve_command_refs(body, separator, 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 d99cca4e8..1c8d727d7 100644 --- a/src/specify_cli/shared_infra.py +++ b/src/specify_cli/shared_infra.py @@ -272,27 +272,56 @@ _BASH_FORMAT_COMMAND_RE = re.compile( _POWERSHELL_FORMAT_COMMAND_RE = re.compile( r"Format-SpecKitCommand\s+-CommandName\s+(['\"])([A-Za-z0-9_.-]+)\1(?:\s+-RepoRoot\s+[^\r\n]+)?" ) +_PYTHON_FORMAT_COMMAND_RETURN_RE = re.compile( + r'return f"/speckit\{separator\}\{name\}"' +) +_BASH_FORMATTER_RETURN_RE = re.compile( + r'''printf '/speckit%s%s\\n' "\$separator" "\$command_name"''' +) +_POWERSHELL_FORMATTER_RETURN_RE = re.compile( + r'return "/speckit\$separator\$name"' +) -def _format_speckit_command(command_name: str, separator: str) -> str: +def _format_speckit_command( + command_name: str, separator: str, prefix: str = "/" +) -> str: name = command_name.strip().lstrip("/") if name.startswith("speckit."): name = name[len("speckit.") :] elif name.startswith("speckit-"): name = name[len("speckit-") :] name = name.replace(".", separator) - return f"/speckit{separator}{name}" + return f"{prefix}speckit{separator}{name}" -def _resolve_dynamic_command_refs(content: str, separator: str) -> str: +def _resolve_dynamic_command_refs( + content: str, separator: str, prefix: str = "/" +) -> str: """Render script runtime command helpers for managed shared infra copies.""" + bash_prefix = r"\$" if prefix == "$" else prefix content = _BASH_FORMAT_COMMAND_RE.sub( - lambda match: _format_speckit_command(match.group(2), separator), + lambda match: _format_speckit_command( + match.group(2), separator, bash_prefix + ), content, ) - return _POWERSHELL_FORMAT_COMMAND_RE.sub( - lambda match: f"'{_format_speckit_command(match.group(2), separator)}'", + content = _POWERSHELL_FORMAT_COMMAND_RE.sub( + lambda match: f"'{_format_speckit_command(match.group(2), separator, prefix)}'", + content, + ) + content = _BASH_FORMATTER_RETURN_RE.sub( + f'''printf '{prefix}speckit%s%s\\\\n' "$separator" "$command_name"''', + content, + ) + powershell_prefix = "`$" if prefix == "$" else prefix + content = _POWERSHELL_FORMATTER_RETURN_RE.sub( + f'return "{powershell_prefix}speckit$separator$name"', + content, + ) + return _PYTHON_FORMAT_COMMAND_RETURN_RE.sub( + f'return f"{prefix}speckit{{separator}}{{name}}"', content, ) @@ -305,6 +334,7 @@ def refresh_shared_templates( repo_root: Path, console: Any, invoke_separator: str, + invoke_prefix: str = "/", force: bool = False, ) -> None: """Refresh default-sensitive shared templates without touching scripts.""" @@ -336,7 +366,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, invoke_prefix + ) planned_updates.append((dst, rel, content)) for dst, rel, content in planned_updates: @@ -363,6 +395,7 @@ def install_shared_infra( console: Any, force: bool = False, invoke_separator: str = ".", + invoke_prefix: str = "/", refresh_managed: bool = False, refresh_hint: str | None = None, ) -> bool: @@ -516,8 +549,12 @@ def install_shared_infra( 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 = _resolve_dynamic_command_refs(content, invoke_separator) + content = IntegrationBase.resolve_command_refs( + content, invoke_separator, invoke_prefix + ) + content = _resolve_dynamic_command_refs( + content, invoke_separator, invoke_prefix + ) planned_copies.append( ( dst_path, @@ -566,7 +603,9 @@ def install_shared_infra( continue content = src.read_text(encoding="utf-8") - content = IntegrationBase.resolve_command_refs(content, invoke_separator) + content = IntegrationBase.resolve_command_refs( + content, invoke_separator, invoke_prefix + ) planned_templates.append((dst, rel, content)) for dst_path, rel, content, mode in planned_copies: diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index e78ef2361..5f9996180 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -204,19 +204,69 @@ class TestBuildCommandInvocation: def test_skills_core_command(self): from specify_cli.integrations import get_integration i = get_integration("codex") - assert i.build_command_invocation("speckit.plan") == "/speckit-plan" - assert i.build_command_invocation("plan") == "/speckit-plan" + assert i.build_command_invocation("speckit.plan") == "$speckit-plan" + assert i.build_command_invocation("plan") == "$speckit-plan" def test_skills_extension_command(self): from specify_cli.integrations import get_integration i = get_integration("codex") - assert i.build_command_invocation("speckit.git.commit") == "/speckit-git-commit" - assert i.build_command_invocation("git.commit") == "/speckit-git-commit" + assert i.build_command_invocation("speckit.git.commit") == "$speckit-git-commit" + assert i.build_command_invocation("git.commit") == "$speckit-git-commit" def test_skills_extension_command_with_args(self): from specify_cli.integrations import get_integration i = get_integration("codex") - assert i.build_command_invocation("speckit.git.commit", "fix typo") == "/speckit-git-commit fix typo" + assert i.build_command_invocation("speckit.git.commit", "fix typo") == "$speckit-git-commit fix typo" + + @pytest.mark.parametrize("integration_key", ["codex", "zcode"]) + def test_dollar_skill_post_processing_is_idempotent(self, integration_key): + from specify_cli.integrations import get_integration + + content = ( + "---\nname: test\n---\n\n" + "Literal slash invocation: /speckit-plan\n" + "- For each executable hook, output the following based on its flag:\n" + ) + integration = get_integration(integration_key) + once = integration.post_process_skill_content(content) + twice = integration.post_process_skill_content(once) + + assert twice == once + assert once.count("replace dots (`.`) with hyphens") == 1 + assert "$speckit-git-commit" in once + assert "/speckit-plan" in once + + def test_kimi_skill_post_processing_is_idempotent(self): + """Kimi's post_process_skill_content must be idempotent. + + The hook-command note is injected with the /skill: prefix by the base + class (via get_invocation_prefix), so the idempotency check matches on + re-runs without requiring the broad /speckit- -> /skill:speckit- body + replacement to recognise a duplicate. + """ + from specify_cli.integrations import get_integration + + content = ( + "---\nname: test\n---\n\n" + "Literal slash invocation: /speckit-plan\n" + "- For each executable hook, output the following based on its flag:\n" + ) + integration = get_integration("kimi") + once = integration.post_process_skill_content(content) + twice = integration.post_process_skill_content(once) + + assert twice == once + assert once.count("replace dots (`.`) with hyphens") == 1 + assert "/skill:speckit-git-commit" in once + + def test_get_invocation_prefix_skill_colon(self): + """get_invocation_prefix returns '/skill:' for Kimi in skills mode.""" + from specify_cli._invocation_style import get_invocation_prefix + + assert get_invocation_prefix("kimi", True) == "/skill:" + assert get_invocation_prefix("kimi", False) == "/" + assert get_invocation_prefix("codex", True) == "$" + assert get_invocation_prefix("claude", True) == "/" def test_forge_core_command_hyphenated(self): """Forge installs hyphenated slash-commands (/speckit-), so the @@ -268,6 +318,26 @@ class TestResolveCommandRefs: result = IntegrationBase.resolve_command_refs(text, "-") assert result == "Run `/speckit-plan` to plan." + def test_dollar_prefix_core_command(self): + text = "Run `__SPECKIT_COMMAND_PLAN__` to plan." + result = IntegrationBase.resolve_command_refs(text, "-", "$") + assert result == "Run `$speckit-plan` to plan." + + def test_skill_colon_prefix_core_command(self): + text = "Run `__SPECKIT_COMMAND_PLAN__` to plan." + result = IntegrationBase.resolve_command_refs(text, "-", "/skill:") + assert result == "Run `/skill:speckit-plan` to plan." + + def test_process_template_kimi_uses_skill_colon_prefix(self): + """process_template must use /skill: prefix for Kimi without relying on + post_process_skill_content's broad replacement.""" + text = "---\ndescription: test\n---\nRun `__SPECKIT_COMMAND_PLAN__` to plan." + result = IntegrationBase.process_template( + text, "kimi", "sh", invoke_separator="-" + ) + assert "/skill:speckit-plan" in result + assert "/speckit-plan" not in result + def test_multiple_placeholders(self): text = "__SPECKIT_COMMAND_SPECIFY__ then __SPECKIT_COMMAND_PLAN__ then __SPECKIT_COMMAND_TASKS__" result = IntegrationBase.resolve_command_refs(text, ".") diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 6899a5105..f6c08d0a4 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -3,6 +3,7 @@ import io import json import os +import runpy import pytest import yaml @@ -1180,6 +1181,23 @@ class TestSharedInfraCommandRefs: assert "__SPECKIT_COMMAND_" not in content assert "/speckit-tasks" in content + def test_dollar_prefix_in_page_templates(self, tmp_path): + """Dollar-style skills agents get $speckit- in page templates.""" + from specify_cli import _install_shared_infra + + project = tmp_path / "dollar-test" + project.mkdir() + (project / ".specify").mkdir() + + _install_shared_infra( + project, "sh", invoke_separator="-", invoke_prefix="$" + ) + + plan = project / ".specify" / "templates" / "plan-template.md" + content = plan.read_text(encoding="utf-8") + assert "$speckit-plan" 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.""" @@ -1220,6 +1238,48 @@ class TestSharedInfraCommandRefs: assert "/speckit.plan" not in content assert "/speckit.tasks" not in content + @pytest.mark.parametrize("script_type", ["sh", "ps", "py"]) + def test_dollar_prefix_in_shared_scripts(self, tmp_path, script_type): + """Dollar-style skills agents get native prefixes in shared script hints.""" + from specify_cli import _install_shared_infra + + project = tmp_path / f"dollar-script-{script_type}" + project.mkdir() + (project / ".specify").mkdir() + + _install_shared_infra( + project, script_type, invoke_separator="-", invoke_prefix="$" + ) + + if script_type == "py": + state = { + "integration": "codex", + "integration_settings": { + "codex": {"invoke_separator": "-"}, + }, + } + (project / ".specify" / "integration.json").write_text( + json.dumps(state), encoding="utf-8" + ) + common = project / ".specify" / "scripts" / "python" / "common.py" + namespace = runpy.run_path(str(common)) + assert namespace["format_speckit_command"]("plan", project) == ( + "$speckit-plan" + ) + return + + content = self._combined_script_content(project, script_type) + 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 + assert "/speckit-tasks" not in content + if script_type == "sh": + assert r"\$speckit-specify" in content + assert r"\$speckit-plan" in content + assert r"\$speckit-tasks" in content + def test_full_init_claude_resolves_page_templates(self, tmp_path): """Full CLI init with Claude (skills agent) produces hyphen refs in page templates.""" from typer.testing import CliRunner diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index 79c6d3fb5..25551e1dc 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -191,7 +191,7 @@ class SkillsIntegrationTests: "---\n" "name: test\n" "---\n\n" - "- 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 each executable hook, output the following first block:\n" diff --git a/tests/integrations/test_integration_codex.py b/tests/integrations/test_integration_codex.py index ac32dd725..9a4dc5442 100644 --- a/tests/integrations/test_integration_codex.py +++ b/tests/integrations/test_integration_codex.py @@ -12,7 +12,6 @@ class TestCodexIntegration(SkillsIntegrationTests): COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".agents/skills" - class TestCodexInitFlow: """--integration codex creates expected files.""" @@ -98,6 +97,8 @@ class TestCodexHookCommandNote: assert "replace dots" in content, ( "speckit-specify should have dot-to-hyphen hook note" ) + assert "constructing command invocations" in content + assert "constructing slash commands" not in content def test_hook_note_not_in_skills_without_hooks(self): """Skills without hook sections should not get the note.""" diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index ef24c95c2..916c7b83f 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -1276,6 +1276,24 @@ class TestIntegrationInstall: assert "/speckit-specify" in script_content assert "/speckit.specify" not in script_content + def test_install_dollar_skill_into_bare_project_gets_native_shared_refs( + self, tmp_path + ): + """A dollar-style integration supplies its prefix without a default.""" + project = tmp_path / "bare-codex" + project.mkdir() + (project / ".specify").mkdir() + + result = _run_in_project( + project, ["integration", "install", "codex", "--script", "sh"] + ) + + assert result.exit_code == 0, result.output + plan = project / ".specify" / "templates" / "plan-template.md" + plan_content = plan.read_text(encoding="utf-8") + assert "$speckit-plan" in plan_content + assert "/speckit-plan" not in plan_content + def test_install_defers_extension_commands_until_use(self, tmp_path): """Installing a second integration does not register enabled extensions. @@ -2725,7 +2743,7 @@ class TestIntegrationSwitch: assert opts["ai"] == "codex" template = project / ".specify" / "templates" / "plan-template.md" - assert "/speckit-plan" in template.read_text(encoding="utf-8") + assert "$speckit-plan" in template.read_text(encoding="utf-8") def test_failed_switch_rescaffolds_fallback_extensions(self, tmp_path): """Regression (review 3624184343). diff --git a/tests/integrations/test_integration_zcode.py b/tests/integrations/test_integration_zcode.py index f431d3e4a..57abaedff 100644 --- a/tests/integrations/test_integration_zcode.py +++ b/tests/integrations/test_integration_zcode.py @@ -9,7 +9,6 @@ class TestZcodeIntegration(SkillsIntegrationTests): COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".zcode/skills" - class TestZcodeInvocation: """ZCode renders $speckit-* chat invocations (like Codex).""" diff --git a/tests/test_extensions.py b/tests/test_extensions.py index afff0a2f8..4da73327e 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -2884,6 +2884,29 @@ Real body starts here. assert "source: test-ext:commands/hello.md" in content assert "