Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c9ccdf7
fix(integrations): use native dollar skill invocations
BenBtg Jul 21, 2026
81e5f8c
fix(integrations): preserve skill post-process idempotence
BenBtg Jul 21, 2026
6b24210
fix(integrations): preserve literal skill invocations
BenBtg Jul 22, 2026
7bc3911
fix(integrations): preserve shared invocation prefix
BenBtg Jul 22, 2026
e2317aa
fix(integrations): preserve install invocation prefix
BenBtg Jul 22, 2026
4d347be
fix(integrations): preserve dollar refs everywhere
BenBtg Jul 22, 2026
1d148d6
fix(shared-infra): preserve dollar command hints
BenBtg Jul 22, 2026
21823ca
fix(shared-infra): render native helper prefixes
BenBtg Jul 22, 2026
487988e
fix(skills): use invocation-neutral hook guidance
BenBtg Jul 22, 2026
3f082a9
test(integrations): expect native fallback invocation
BenBtg Jul 22, 2026
7be6e56
refactor(integrations): centralize invocation prefix selection
BenBtg Jul 22, 2026
0532576
Merge remote-tracking branch 'origin/main' into fix/native-invocation…
BenBtg Jul 22, 2026
60bd86d
fix(integrations): add Kimi /skill: prefix and fix docstrings
BenBtg Jul 23, 2026
633e8f9
fix(agents): use get_invocation_prefix for Kimi in register_commands
BenBtg Jul 23, 2026
b19a8a8
fix(agents): remove unused is_dollar_skills_agent import
BenBtg Jul 23, 2026
ed6549f
fix(integrations): use get_invocation_prefix in post_process_skill_co…
BenBtg Jul 23, 2026
7ece07a
fix(presets): use get_invocation_prefix in _resolve_skill_command_refs
BenBtg Jul 23, 2026
c3d5403
docs(presets): document /skill: form in _resolve_skill_command_refs
BenBtg Jul 23, 2026
ba75696
test(integrations): add Kimi /skill: prefix coverage
BenBtg Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -124,6 +125,7 @@ def _refresh_shared_templates(
repo_root=_repo_root(),
console=console,
invoke_separator=invoke_separator,
invoke_prefix=invoke_prefix,
force=force,
)

Expand All @@ -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:
Expand Down Expand Up @@ -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,
)
Expand All @@ -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:
Expand All @@ -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,
)
Expand Down
18 changes: 18 additions & 0 deletions src/specify_cli/_invocation_style.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
}
)

# 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.
Expand All @@ -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:"
Comment thread
BenBtg marked this conversation as resolved.
return "/"


def is_slash_skills_agent(selected_ai: str | None, ai_skills_enabled: bool) -> bool:
"""Return ``True`` if *selected_ai* uses ``/speckit-<name>`` invocations.

Expand Down
8 changes: 4 additions & 4 deletions src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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)

Expand Down
7 changes: 7 additions & 0 deletions src/specify_cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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"
Expand Down
12 changes: 12 additions & 0 deletions src/specify_cli/integration_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
4 changes: 4 additions & 0 deletions src/specify_cli/integrations/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/specify_cli/integrations/_install_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/specify_cli/integrations/_migrate_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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]."
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Expand Down
47 changes: 37 additions & 10 deletions src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,15 @@

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

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"
)
Expand Down Expand Up @@ -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_<NAME>__`` placeholders with invocations.

Each placeholder encodes a command name in upper-case with
Expand All @@ -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,
)

Expand Down Expand Up @@ -838,7 +847,14 @@ def process_template(
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 = (
"$"
if is_dollar_skills_agent(agent_name, invoke_separator == "-")
else "/"
)
content = IntegrationBase.resolve_command_refs(
content, invoke_separator, invocation_prefix
)

return content

Expand Down Expand Up @@ -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-<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``
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 10 additions & 6 deletions src/specify_cli/presets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -1261,13 +1262,15 @@ def _resolve_skill_command_refs(

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.
"""
Expand All @@ -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."""
Expand Down
Loading