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:
Ben Buttigieg
2026-07-28 15:48:40 +01:00
committed by GitHub
parent 809b4c5e26
commit 655a3cb8ca
21 changed files with 423 additions and 41 deletions

View File

@@ -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, ".")

View File

@@ -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

View File

@@ -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"

View File

@@ -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."""

View File

@@ -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).

View File

@@ -9,7 +9,6 @@ class TestZcodeIntegration(SkillsIntegrationTests):
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".zcode/skills"
class TestZcodeInvocation:
"""ZCode renders $speckit-* chat invocations (like Codex)."""