diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 1d5a34073c..0454b6923f 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 2b5f189488..30b42eb081 100644 --- a/src/specify_cli/_invocation_style.py +++ b/src/specify_cli/_invocation_style.py @@ -29,6 +29,9 @@ } ) +# 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 09429f7a69..00b37a8bdc 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -15,6 +15,7 @@ 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 @@ def register_commands( # 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"] @@ -755,7 +755,7 @@ def register_commands( # (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 ccdd7b0a1b..0d96e2b6c5 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -183,6 +183,7 @@ def init( 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 init( 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 9bf8aeac96..c61d8ea1e2 100644 --- a/src/specify_cli/integration_runtime.py +++ b/src/specify_cli/integration_runtime.py @@ -5,6 +5,7 @@ from collections.abc import Callable from typing import Any +from ._invocation_style import get_invocation_prefix from .integration_state import integration_setting, integration_settings @@ -92,3 +93,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 415668745b..0268a4df40 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -11,6 +11,7 @@ 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 4d7bb44f9b..372a335fd3 100644 --- a/src/specify_cli/integrations/_install_commands.py +++ b/src/specify_cli/integrations/_install_commands.py @@ -8,6 +8,7 @@ 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 7116a744c4..88815f333d 100644 --- a/src/specify_cli/integrations/_migrate_commands.py +++ b/src/specify_cli/integrations/_migrate_commands.py @@ -9,6 +9,7 @@ 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, ) @@ -327,6 +328,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]." @@ -557,6 +561,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 @@ -592,6 +599,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 ccc8587709..049ec26ed6 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -27,6 +27,7 @@ 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 @@ 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 @@ def install_scripts( 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 @@ def resolve_command_refs(content: str, separator: str = ".") -> str: * ``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,14 @@ 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) + invocation_prefix = ( + "$" + if is_dollar_skills_agent(agent_name, invoke_separator == "-") + else "/" + ) + content = IntegrationBase.resolve_command_refs( + content, invoke_separator, invocation_prefix + ) return content @@ -1520,18 +1536,21 @@ def skills_dest(self, project_root: Path) -> Path: 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 +1559,11 @@ def _inject_hook_command_note(content: str) -> str: 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 +1597,13 @@ def post_process_skill_content(self, content: str) -> str: 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/presets/__init__.py b/src/specify_cli/presets/__init__.py index 52494fede7..7d47c8fb35 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -29,6 +29,7 @@ from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority from .._init_options import is_ai_skills_enabled +from .._invocation_style import get_invocation_prefix from ..integrations.base import IntegrationBase from .._utils import dump_frontmatter, version_satisfies from ..shared_infra import ( @@ -1261,13 +1262,15 @@ def _resolve_skill_command_refs( 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. """ @@ -1288,7 +1291,8 @@ def _resolve_skill_command_refs( 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 d99cca4e85..1c8d727d73 100644 --- a/src/specify_cli/shared_infra.py +++ b/src/specify_cli/shared_infra.py @@ -272,27 +272,56 @@ def _write_shared_bytes( _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, + ) + 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, ) - return _POWERSHELL_FORMAT_COMMAND_RE.sub( - lambda match: f"'{_format_speckit_command(match.group(2), separator)}'", + 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 _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 = _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 _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, 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 e78ef23613..5264bfcfc6 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -204,19 +204,69 @@ def test_base_extension_command_bare(self): 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,16 @@ def test_hyphen_separator_core_command(self): 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_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 6899a5105a..f6c08d0a4e 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 @@ def test_hyphen_separator_in_page_templates(self, tmp_path): 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 @@ 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", "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 79c6d3fb50..25551e1dc7 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -191,7 +191,7 @@ def test_hook_note_injected_for_each_instruction_independently(self): "---\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 ac32dd7250..9a4dc5442b 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 @@ def test_hook_note_injected_in_skills_with_hooks(self, tmp_path): 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 748b707d66..9955000881 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -1256,6 +1256,24 @@ def test_install_bare_project_gets_shared_infra(self, tmp_path): 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. @@ -2289,7 +2307,7 @@ 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") + assert "$speckit-plan" in template.read_text(encoding="utf-8") class TestIntegrationUpgrade: diff --git a/tests/integrations/test_integration_zcode.py b/tests/integrations/test_integration_zcode.py index f431d3e4a0..57abaedffe 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 00d8da152f..f405f8068e 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -2814,6 +2814,29 @@ def test_codex_skill_registration_writes_skill_frontmatter(self, extension_dir, assert "source: test-ext:commands/hello.md" in content assert "