mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
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-<name> rather than /speckit-<name>. 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-<cmd> 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
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -29,6 +29,9 @@ CONDITIONAL_SLASH_AGENTS: frozenset[str] = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
# Agents that render /skill:<name> (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-<name>`` 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-<name>`` invocations.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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_<NAME>__`` 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_<NAME>__ 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-<stem>`` (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,
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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_<NAME>__`` placeholder into the matching
|
||||
slash-command invocation — ``/speckit-<cmd>`` for a ``-`` separator,
|
||||
``/speckit.<cmd>`` for ``.`` — the same rendering the command layer
|
||||
applies via ``CommandRegistrar.register_commands()``.
|
||||
agent-native invocation -- ``/speckit-<cmd>`` or ``$speckit-<cmd>`` for
|
||||
a ``-`` separator, ``/speckit.<cmd>`` for ``.``, or
|
||||
``/skill:speckit-<cmd>`` 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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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-<name>), 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, ".")
|
||||
|
||||
@@ -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-<name> 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.<name> 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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -9,7 +9,6 @@ class TestZcodeIntegration(SkillsIntegrationTests):
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".zcode/skills"
|
||||
|
||||
|
||||
class TestZcodeInvocation:
|
||||
"""ZCode renders $speckit-* chat invocations (like Codex)."""
|
||||
|
||||
|
||||
@@ -2884,6 +2884,29 @@ Real body starts here.
|
||||
assert "source: test-ext:commands/hello.md" in content
|
||||
assert "<!-- Extension:" not in content
|
||||
|
||||
def test_codex_skill_registration_uses_dollar_command_refs(
|
||||
self, extension_dir, project_dir
|
||||
):
|
||||
"""Codex extension skills use the native dollar invocation prefix."""
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
skills_dir.mkdir(parents=True)
|
||||
command = extension_dir / "commands" / "hello.md"
|
||||
command.write_text(
|
||||
"---\ndescription: Test hello command\n---\n\nRun __SPECKIT_COMMAND_PLAN__.",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent(
|
||||
"codex", manifest, extension_dir, project_dir
|
||||
)
|
||||
|
||||
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
assert "$speckit-plan" in content
|
||||
assert "/speckit-plan" not in content
|
||||
|
||||
def test_codex_skill_registration_resolves_script_placeholders(self, project_dir, temp_dir):
|
||||
"""Codex SKILL.md overrides should resolve script placeholders."""
|
||||
import yaml
|
||||
|
||||
@@ -4389,6 +4389,35 @@ class TestPresetSkills:
|
||||
assert "__SPECKIT_COMMAND_" not in content, "raw command token leaked on restore"
|
||||
assert "/speckit-plan" in content
|
||||
|
||||
def test_restore_skill_preserves_dollar_command_refs(self, project_dir, temp_dir):
|
||||
"""Dollar-style core refs remain native when a preset skill is removed."""
|
||||
self._write_init_options(project_dir, ai="zcode")
|
||||
skills_dir = project_dir / ".zcode" / "skills"
|
||||
self._create_skill(skills_dir, "speckit-specify")
|
||||
|
||||
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
||||
core_cmds.mkdir(parents=True, exist_ok=True)
|
||||
raw_core = (
|
||||
"---\ndescription: Core specify\n---\n\n"
|
||||
"Then run `__SPECKIT_COMMAND_PLAN__`.\n"
|
||||
)
|
||||
(core_cmds / "specify.md").write_text(raw_core)
|
||||
|
||||
preset_dir = self._create_command_preset(
|
||||
temp_dir,
|
||||
"dollar-cmdref-restore",
|
||||
"speckit.specify",
|
||||
"Override specify",
|
||||
"Override body\n",
|
||||
)
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
manager.remove("dollar-cmdref-restore")
|
||||
|
||||
content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
|
||||
assert "$speckit-plan" in content
|
||||
assert "/speckit-plan" not in content
|
||||
|
||||
def test_reconcile_override_skill_resolves_command_refs(self, project_dir, temp_dir):
|
||||
"""Reconcile's project-override restore must resolve command tokens (issue #2717).
|
||||
|
||||
|
||||
@@ -466,6 +466,42 @@ def test_bash_command_hint_preserves_hyphens_inside_segments(tasks_repo: Path) -
|
||||
assert result.stdout.strip() == "/speckit.jira.sync-status"
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_installed_bash_formatter_uses_dollar_prefix(tmp_path: Path) -> None:
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "bash-dollar-prefix"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
_install_shared_infra(
|
||||
project, "sh", invoke_separator="-", invoke_prefix="$"
|
||||
)
|
||||
_write_integration_state(project, "codex", "-")
|
||||
|
||||
result = _run_bash_format_command(project, "plan")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "$speckit-plan"
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_installed_bash_formatter_uses_skill_colon_prefix(tmp_path: Path) -> None:
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "bash-skill-colon-prefix"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
_install_shared_infra(
|
||||
project, "sh", invoke_separator="-", invoke_prefix="/skill:"
|
||||
)
|
||||
_write_integration_state(project, "kimi", "-")
|
||||
|
||||
result = _run_bash_format_command(project, "plan")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "/skill:speckit-plan"
|
||||
|
||||
|
||||
def _install_broken_json_tool_stubs(repo: Path) -> Path:
|
||||
"""Create a bin dir with `jq` and `python3` stubs that exist but fail.
|
||||
|
||||
@@ -779,6 +815,24 @@ def test_powershell_command_hint_normalizes_mixed_separators(
|
||||
assert result.stdout.strip() == "/speckit-git-commit"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_installed_powershell_formatter_uses_dollar_prefix(tmp_path: Path) -> None:
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "powershell-dollar-prefix"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
_install_shared_infra(
|
||||
project, "ps", invoke_separator="-", invoke_prefix="$"
|
||||
)
|
||||
_write_integration_state(project, "codex", "-")
|
||||
|
||||
result = _run_powershell_format_command(project, "plan")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "$speckit-plan"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_powershell_command_hint_preserves_hyphens_inside_segments(
|
||||
tasks_repo: Path,
|
||||
|
||||
Reference in New Issue
Block a user